Merge remote-tracking branch 'origin/main' into update-img-config

* origin/main: (31 commits)
  Add group chat join links to supported embed types (#10454)
  Disable overscroll on Android to fix settle discrepancy (#10717)
  Fix labeller highlight when selected on bottom bar (#10716)
  Use correct `Plural` macro in InviteLinkDialog (#10723)
  Fix invalid `zero` plural category and add plural formatting (#10722)
  Nightly source-language update
  `expo-image` tweaks (#10710)
  Make extra sure the drawer gesture doesn't accidentally trigger on Android (#10713)
  Fix image peek in carousels for quote posts (#10688)
  Compile i18n before running lint (#10711)
  [Android] Fix horizontal swipes being cancelled when leaving bounds (#10712)
  Enable ESLint errors and suppress existing violations (#10490)
  Hide accept button for locked chats (#10696)
  Mount message dialogs once at the list level (#10435)
  Fix alt text selection in lightbox on Android (and iOS) (#10698)
  Mention default `<Text>` style in CLAUDE.md (#10705)
  Use square icons for labellers in Automation Label preview card (#10701)
  [ALF] Set text to `leading_snug` by default (#10627)
  Nightly source-language update
  Transpile `@atproto/api` package for older browser support (#10700)
  ...
This commit is contained in:
Eric Bailey
2026-06-04 10:42:24 -05:00
133 changed files with 6200 additions and 1265 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]
+3 -3
View File
@@ -128,7 +128,7 @@ jobs:
- name: 🔔 Notify Slack of Production Build - name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }} if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v2.1.1 uses: slackapi/slack-github-action@v3.0.3
with: with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook webhook-type: incoming-webhook
@@ -138,7 +138,7 @@ jobs:
- name: 🔔 Notify Slack of Testflight Build - name: 🔔 Notify Slack of Testflight Build
if: ${{ inputs.profile != 'production' }} if: ${{ inputs.profile != 'production' }}
uses: slackapi/slack-github-action@v2.1.1 uses: slackapi/slack-github-action@v3.0.3
with: with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook webhook-type: incoming-webhook
@@ -174,7 +174,7 @@ jobs:
- name: 🔔 Notify Slack of Production APK Build - name: 🔔 Notify Slack of Production APK Build
if: ${{ inputs.profile == 'production' }} if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v2.1.1 uses: slackapi/slack-github-action@v3.0.3
with: with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook webhook-type: incoming-webhook
+1 -1
View File
@@ -155,7 +155,7 @@ jobs:
- name: 🔔 Notify Slack of Production Build - name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }} if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v2.1.1 uses: slackapi/slack-github-action@v3.0.3
with: with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook webhook-type: incoming-webhook
@@ -71,18 +71,18 @@ jobs:
profile: ${{ inputs.channel || 'testflight' }} profile: ${{ inputs.channel || 'testflight' }}
previous-commit-tag: ${{ inputs.runtimeVersion }} previous-commit-tag: ${{ inputs.runtimeVersion }}
- name: Lint check
run: pnpm lint
- name: Prettier check
run: pnpm prettier --check .
- name: 🔤 Compile translations - name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors - 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 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 - name: Type check
run: pnpm typecheck run: pnpm typecheck
@@ -386,7 +386,7 @@ jobs:
path: build.apk path: build.apk
- name: 🔔 Notify Slack - name: 🔔 Notify Slack
uses: slackapi/slack-github-action@v2.1.1 uses: slackapi/slack-github-action@v3.0.3
with: with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook webhook-type: incoming-webhook
+3 -23
View File
@@ -21,7 +21,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
job: [lint, prettier] job: [lint, prettier, typecheck]
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -62,6 +62,8 @@ jobs:
command: pnpm install --frozen-lockfile command: pnpm install --frozen-lockfile
attempt_limit: 3 attempt_limit: 3
attempt_delay: 2000 attempt_delay: 2000
- name: Check & compile i18n
run: pnpm intl:build
- name: Lint checks - name: Lint checks
run: pnpm ${{ matrix.job }} run: pnpm ${{ matrix.job }}
# Aggregates the matrix results into a single stable check name so branch # Aggregates the matrix results into a single stable check name so branch
@@ -80,28 +82,6 @@ jobs:
run: | run: |
echo "linting result: $RESULT" echo "linting result: $RESULT"
test "$RESULT" = "success" test "$RESULT" = "success"
typechecking:
name: Run typecheck
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: pnpm install
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Check & compile i18n
run: pnpm intl:build
- name: Type check
run: pnpm typecheck
testing: testing:
name: Run tests name: Run tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
+6 -6
View File
@@ -94,7 +94,7 @@ jobs:
core.setOutput('head-ref', pr.data.head.ref); core.setOutput('head-ref', pr.data.head.ref);
- name: 💬 Drop a comment - name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2 uses: marocchino/sticky-pull-request-comment@v3
with: with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }} header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }} number: ${{ github.event.issue.number }}
@@ -128,15 +128,15 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Lint check
run: pnpm lint
- name: 🔤 Compile translations - name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors - 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 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 - name: Type check
run: pnpm typecheck run: pnpm typecheck
@@ -181,7 +181,7 @@ jobs:
RUNTIME_VERSION: RUNTIME_VERSION:
- name: 💬 Drop a comment - name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2 uses: marocchino/sticky-pull-request-comment@v3
env: env:
ISSUE_NUMBER: ${{ github.event.issue.number }} ISSUE_NUMBER: ${{ github.event.issue.number }}
with: with:
@@ -198,7 +198,7 @@ jobs:
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖* *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Drop a comment - name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2 uses: marocchino/sticky-pull-request-comment@v3
if: failure() if: failure()
with: with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }} header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
+3 -3
View File
@@ -96,7 +96,7 @@ jobs:
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico" excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
- name: 💬 Drop a comment - name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2 uses: marocchino/sticky-pull-request-comment@v3
with: with:
header: bundle-diff header: bundle-diff
message: | message: |
@@ -133,7 +133,7 @@ jobs:
profile: pull-request profile: pull-request
- name: 💬 Drop a comment - name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2 uses: marocchino/sticky-pull-request-comment@v3
if: ${{ steps.fingerprint.outputs.includes-changes }} if: ${{ steps.fingerprint.outputs.includes-changes }}
with: with:
header: fingerprint-diff header: fingerprint-diff
@@ -151,7 +151,7 @@ jobs:
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖* *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Delete comment - name: 💬 Delete comment
uses: marocchino/sticky-pull-request-comment@v2 uses: marocchino/sticky-pull-request-comment@v3
if: ${{ !steps.fingerprint.outputs.includes-changes }} if: ${{ !steps.fingerprint.outputs.includes-changes }}
with: with:
header: fingerprint-diff header: fingerprint-diff
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Generate GitHub App Token - name: Generate GitHub App Token
id: app-token id: app-token
uses: actions/create-github-app-token@v1 uses: actions/create-github-app-token@v3
with: with:
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }} app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
private-key: ${{ secrets.SYNC_INTERNAL_PK }} private-key: ${{ secrets.SYNC_INTERNAL_PK }}
+7 -3
View File
@@ -206,7 +206,7 @@ function MyComponent() {
return ( return (
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}> <View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>Hello</Text> <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_high]}>Hello</Text>
</View> </View>
) )
} }
@@ -414,12 +414,16 @@ import {Text, H1, H2, P} from '#/components/Typography'
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1> <H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
<P>Paragraph text with default styling.</P> <P>Paragraph text with default styling.</P>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text> <Text style={[a.text_md, t.atoms.text_contrast_medium]}>Custom text</Text>
// For text with emoji, add the emoji prop // For text with emoji, add the emoji prop. User-generated text (e.g. display names)
// will almost certainly contain emoji, so only omit it when the text is static and
// does not contain an emoji
<Text emoji>Hello! 👋</Text> <Text emoji>Hello! 👋</Text>
``` ```
The `Text` component's default style is `[a.text_sm, a.leading_snug, t.atoms.text]`.
### TextField ### TextField
```tsx ```tsx
+45
View File
@@ -1,6 +1,7 @@
import {describe, expect, it} from '@jest/globals' import {describe, expect, it} from '@jest/globals'
import { import {
getChatInviteCodeFromUrl,
isPossiblyAUrl, isPossiblyAUrl,
isTrustedUrl, isTrustedUrl,
linkRequiresWarning, linkRequiresWarning,
@@ -178,3 +179,47 @@ describe('isTrustedUrl', () => {
expect(output).toEqual(expected) expect(output).toEqual(expected)
}) })
}) })
describe('getChatInviteCodeFromUrl', () => {
type Case = [string, string | undefined]
const cases: Case[] = [
['https://bsky.app/c/abcdefg', 'abcdefg'],
['https://bsky.app/c/abcdefghij', 'abcdefghij'],
// http is not recognized as a bsky.app url
['http://bsky.app/c/abcdefg', undefined],
['https://bsky.app/c/abcdefg?utm=foo', 'abcdefg'],
['https://bsky.app/c/abcdefg#section', 'abcdefg'],
['/c/abcdefg', 'abcdefg'],
['/c/abcdefg?utm=foo', 'abcdefg'],
['/c/abcdefg#section', 'abcdefg'],
// too short
['https://bsky.app/c/abcdef', undefined],
['/c/abcdef', undefined],
// too long
['https://bsky.app/c/abcdefghijk', undefined],
['/c/abcdefghijk', undefined],
// invalid characters
['https://bsky.app/c/abc-def', undefined],
['/c/abc def', undefined],
// trailing path
['https://bsky.app/c/abcdefg/extra', undefined],
['/c/abcdefg/extra', undefined],
// wrong path
['https://bsky.app/profile/abcdefg', undefined],
['https://bsky.app/c', undefined],
// wrong host
['https://example.com/c/abcdefg', undefined],
// not a url, not a path
['c/abcdefg', undefined],
['abcdefg', undefined],
['', undefined],
// malformed url
['https://[invalid/c/abcdefg', undefined],
]
it.each(cases)('given input %p, returns %p', (input, expected) => {
expect(getChatInviteCodeFromUrl(input)).toEqual(expected)
})
})
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/inbox", server.WebGeneric)
e.GET("/messages/:conversation", server.WebGeneric) e.GET("/messages/:conversation", server.WebGeneric)
e.GET("/messages/:conversation/settings", server.WebGeneric) e.GET("/messages/:conversation/settings", server.WebGeneric)
e.GET("/messages/:conversation/requests", server.WebGeneric)
// profile endpoints; only first populates info // profile endpoints; only first populates info
e.GET("/profile/:handleOrDID", server.WebProfile) e.GET("/profile/:handleOrDID", server.WebProfile)
+1829 -5
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', 'react-native/no-inline-styles': 'off',
...reactNativeA11y.configs.all.rules, ...reactNativeA11y.configs.all.rules,
'react-compiler/react-compiler': 'warn', 'react-compiler/react-compiler': 'warn',
// TODO: Fix these and set to error 'react-hooks/set-state-in-effect': 'error',
'react-hooks/set-state-in-effect': 'warn', 'react-hooks/purity': 'error',
'react-hooks/purity': 'warn', 'react-hooks/refs': 'error',
'react-hooks/refs': 'warn', 'react-hooks/immutability': 'error',
'react-hooks/immutability': 'warn',
/** /**
* Import sorting * Import sorting
@@ -235,9 +234,8 @@ export default defineConfig(
}, },
], ],
/** /**
* Maintain previous behavior - these are stricter in typescript-eslint * Maintain previous behavior via eslint-suppressions.json - these are
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too * stricter in typescript-eslint v8. `off` ones are a bit too nit-picky.
* nit-picky
*/ */
'@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/ban-ts-comment': 'off',
@@ -247,18 +245,18 @@ export default defineConfig(
'@typescript-eslint/unbound-method': 'off', '@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-member-access': 'warn', '@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-call': 'warn', '@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'warn', '@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/require-await': 'warn', '@typescript-eslint/require-await': 'error',
'@typescript-eslint/no-unsafe-enum-comparison': 'warn', '@typescript-eslint/no-unsafe-enum-comparison': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'warn', '@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/no-redundant-type-constituents': 'warn', '@typescript-eslint/no-redundant-type-constituents': 'error',
'@typescript-eslint/no-duplicate-type-constituents': 'warn', '@typescript-eslint/no-duplicate-type-constituents': 'error',
'@typescript-eslint/no-base-to-string': 'warn', '@typescript-eslint/no-base-to-string': 'error',
'@typescript-eslint/prefer-promise-reject-errors': 'warn', '@typescript-eslint/prefer-promise-reject-errors': 'error',
'@typescript-eslint/await-thenable': 'warn', '@typescript-eslint/await-thenable': 'error',
'no-restricted-imports': [ 'no-restricted-imports': [
'error', 'error',
@@ -41,11 +41,14 @@ class BottomSheetView(
private val screenHeight: Float = private val screenHeight: Float =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
// API 35+: edge-to-edge is mandatory, heightPixels is the full display // API 35+: edge-to-edge is mandatory, heightPixels is the full display
context.resources.displayMetrics.heightPixels.toFloat() context.resources.displayMetrics.heightPixels
.toFloat()
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { } else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics // API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
wm.currentWindowMetrics.bounds.height().toFloat() wm.currentWindowMetrics.bounds
.height()
.toFloat()
} else { } else {
// API < 30: currentWindowMetrics not available, use getRealSize // API < 30: currentWindowMetrics not available, use getRealSize
// which includes system bars (heightPixels may exclude them) // which includes system bars (heightPixels may exclude them)
@@ -166,6 +169,7 @@ class BottomSheetView(
when { when {
// Full height sheets // Full height sheets
contentHeight >= screenHeight -> 0.99f contentHeight >= screenHeight -> 0.99f
else -> this.clampRatio(this.getTargetHeight() / screenHeight) else -> this.clampRatio(this.getTargetHeight() / screenHeight)
} }
@@ -271,8 +275,9 @@ class BottomSheetView(
} }
// Apply deferred layout update after gesture completes // Apply deferred layout update after gesture completes
if (newState != BottomSheetBehavior.STATE_DRAGGING && if (newState != BottomSheetBehavior.STATE_DRAGGING &&
newState != BottomSheetBehavior.STATE_SETTLING && newState != BottomSheetBehavior.STATE_SETTLING &&
pendingLayoutUpdate) { pendingLayoutUpdate
) {
pendingLayoutUpdate = false pendingLayoutUpdate = false
updateLayout() updateLayout()
} }
@@ -292,7 +297,6 @@ class BottomSheetView(
if (!fullHeight) { if (!fullHeight) {
this.startObservingContentHeight() this.startObservingContentHeight()
} }
} }
fun updateLayout() { fun updateLayout() {
@@ -365,17 +369,18 @@ class BottomSheetView(
val innerViewGroup = this.innerView as? ViewGroup ?: return val innerViewGroup = this.innerView as? ViewGroup ?: return
val listener = OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom -> val listener =
val newHeight = bottom - top OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
val oldHeight = oldBottom - oldTop val newHeight = bottom - top
if (newHeight != oldHeight) { val oldHeight = oldBottom - oldTop
val contentHeight = getContentHeight() if (newHeight != oldHeight) {
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) { val contentHeight = getContentHeight()
lastObservedContentHeight = contentHeight if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
updateLayout() lastObservedContentHeight = contentHeight
updateLayout()
}
} }
} }
}
val children = mutableListOf<View>() val children = mutableListOf<View>()
for (i in 0 until innerViewGroup.childCount) { for (i in 0 until innerViewGroup.childCount) {
+1
View File
@@ -102,6 +102,7 @@
"@bsky.app/expo-image-crop-tool": "^0.5.1", "@bsky.app/expo-image-crop-tool": "^0.5.1",
"@bsky.app/expo-scroll-edge-effect": "^0.1.4", "@bsky.app/expo-scroll-edge-effect": "^0.1.4",
"@bsky.app/expo-translate-text": "^0.2.9", "@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/peek-menu": "^0.2.4",
"@bsky.app/react-native-mmkv": "2.12.5", "@bsky.app/react-native-mmkv": "2.12.5",
"@bsky.app/sift": "^0.3.8", "@bsky.app/sift": "^0.3.8",
"@bsky.app/tapper": "^0.5.7", "@bsky.app/tapper": "^0.5.7",
+16
View File
@@ -268,6 +268,9 @@ importers:
'@bsky.app/expo-translate-text': '@bsky.app/expo-translate-text':
specifier: ^0.2.9 specifier: ^0.2.9
version: 0.2.9(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) version: 0.2.9(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/peek-menu':
specifier: ^0.2.4
version: 0.2.4(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/react-native-mmkv': '@bsky.app/react-native-mmkv':
specifier: 2.12.5 specifier: 2.12.5
version: 2.12.5(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) version: 2.12.5(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -1649,6 +1652,13 @@ packages:
react: '*' react: '*'
react-native: '*' react-native: '*'
'@bsky.app/peek-menu@0.2.4':
resolution: {integrity: sha512-3E5FwgCXMU6baye3NWoBKih3SCh6s8AgAtxN523YBHZGC5TxpjGaBsjFi765y31nKmTll3QH7x4wtDgYqVvCQg==}
peerDependencies:
expo: '*'
react: '*'
react-native: '*'
'@bsky.app/react-native-mmkv@2.12.5': '@bsky.app/react-native-mmkv@2.12.5':
resolution: {integrity: sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg==} resolution: {integrity: sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg==}
peerDependencies: peerDependencies:
@@ -10461,6 +10471,12 @@ snapshots:
react: 19.1.0 react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
'@bsky.app/peek-menu@0.2.4(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
'@bsky.app/react-native-mmkv@2.12.5(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': '@bsky.app/react-native-mmkv@2.12.5(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies: dependencies:
react: 19.1.0 react: 19.1.0
+6 -6
View File
@@ -47,18 +47,18 @@ import {
import {readLastActiveAccount} from '#/state/session/util' import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer' import {Provider as ComposerProvider} from '#/state/shell/composer'
import {Provider as LandingProvider} from '#/state/shell/landing'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as OnboardingProvider} from '#/state/shell/onboarding' import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {TestCtrls} from '#/view/com/testing/TestCtrls' import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {Shell} from '#/view/shell' import {Shell} from '#/view/shell'
import {atoms as a, ThemeProvider as Alf} from '#/alf' import {atoms as a, ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu' import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {useLandingEntry} from '#/components/hooks/useLandingEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state' import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay' import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
@@ -113,7 +113,7 @@ function InnerApp() {
const {resumeSession} = useSessionApi() const {resumeSession} = useSessionApi()
const theme = useColorModeTheme() const theme = useColorModeTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const hasCheckedReferrer = useStarterPackEntry() const hasCheckedLanding = useLandingEntry()
// init // init
useEffect(() => { useEffect(() => {
@@ -146,7 +146,7 @@ function InnerApp() {
<Alf theme={theme}> <Alf theme={theme}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<ContextMenuProvider> <ContextMenuProvider>
<Splash isReady={isReady && hasCheckedReferrer}> <Splash isReady={isReady && hasCheckedLanding}>
<VideoVolumeProvider> <VideoVolumeProvider>
<Fragment <Fragment
// Resets the entire tree below when it changes: // Resets the entire tree below when it changes:
@@ -246,12 +246,12 @@ function App() {
<LightboxStateProvider> <LightboxStateProvider>
<PortalProvider> <PortalProvider>
<BottomSheetProvider> <BottomSheetProvider>
<StarterPackProvider> <LandingProvider>
<SafeAreaProvider <SafeAreaProvider
initialMetrics={initialWindowMetrics}> initialMetrics={initialWindowMetrics}>
<InnerApp /> <InnerApp />
</SafeAreaProvider> </SafeAreaProvider>
</StarterPackProvider> </LandingProvider>
</BottomSheetProvider> </BottomSheetProvider>
</PortalProvider> </PortalProvider>
</LightboxStateProvider> </LightboxStateProvider>
+6 -6
View File
@@ -40,17 +40,17 @@ import {
import {readLastActiveAccount} from '#/state/session/util' import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer' import {Provider as ComposerProvider} from '#/state/shell/composer'
import {Provider as LandingProvider} from '#/state/shell/landing'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as OnboardingProvider} from '#/state/shell/onboarding' import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {Shell} from '#/view/shell/index' import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf' import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu' import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {useLandingEntry} from '#/components/hooks/useLandingEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state' import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay' import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
@@ -92,7 +92,7 @@ function InnerApp() {
const {resumeSession} = useSessionApi() const {resumeSession} = useSessionApi()
const theme = useColorModeTheme() const theme = useColorModeTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const hasCheckedReferrer = useStarterPackEntry() const hasCheckedLanding = useLandingEntry()
// init // init
useEffect(() => { useEffect(() => {
@@ -125,7 +125,7 @@ function InnerApp() {
<Alf theme={theme}> <Alf theme={theme}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<ContextMenuProvider> <ContextMenuProvider>
<Splash isReady={isReady && hasCheckedReferrer}> <Splash isReady={isReady && hasCheckedLanding}>
<VideoVolumeProvider> <VideoVolumeProvider>
<ActiveVideoProvider> <ActiveVideoProvider>
<Fragment <Fragment
@@ -224,9 +224,9 @@ function App() {
<DialogStateProvider> <DialogStateProvider>
<LightboxStateProvider> <LightboxStateProvider>
<PortalProvider> <PortalProvider>
<StarterPackProvider> <LandingProvider>
<InnerApp /> <InnerApp />
</StarterPackProvider> </LandingProvider>
</PortalProvider> </PortalProvider>
</LightboxStateProvider> </LightboxStateProvider>
</DialogStateProvider> </DialogStateProvider>
+20
View File
@@ -44,6 +44,7 @@ import {
type State, type State,
} from '#/lib/routes/types' } from '#/lib/routes/types'
import {bskyTitle} from '#/lib/strings/headings' import {bskyTitle} from '#/lib/strings/headings'
import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers'
import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useLoggedOutViewControls} from '#/state/shell/logged-out'
@@ -81,6 +82,7 @@ import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation' import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings' import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings'
import {MessagesInboxScreen} from '#/screens/Messages/Inbox' import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
import {MessagesJoinRequestsScreen} from '#/screens/Messages/JoinRequests'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings' import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation' import {ModerationScreen} from '#/screens/Moderation'
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings' import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
@@ -487,6 +489,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => MessagesConversationSettingsScreen} getComponent={() => MessagesConversationSettingsScreen}
options={{title: title(msg`Group chat settings`), requireAuth: true}} options={{title: title(msg`Group chat settings`), requireAuth: true}}
/> />
<Stack.Screen
name="MessagesJoinRequests"
getComponent={() => MessagesJoinRequestsScreen}
options={{title: title(msg`Requests to join`), requireAuth: true}}
/>
<Stack.Screen <Stack.Screen
name="MessagesSettings" name="MessagesSettings"
getComponent={() => MessagesSettingsScreen} getComponent={() => MessagesSettingsScreen}
@@ -797,6 +804,19 @@ const LINKING = {
return buildStateObject('Flat', 'Home', params) return buildStateObject('Flat', 'Home', params)
} }
// Chat invite URLs (`/c/:code`) are handled by `useIntentHandler`, which
// opens the GroupChatJoinDialog (or the logged-out join flow). Route the
// path to Home so the dialog overlays Home instead of NotFound. On native,
// react-navigation strips the `bluesky://` prefix and passes the path
// without a leading slash, so normalize before matching.
const normalizedPath = path.startsWith('/') ? path : `/${path}`
if (CHAT_INVITE_CODE_REGEX.test(normalizedPath.split('?')[0])) {
if (IS_NATIVE) {
return buildStateObject('HomeTab', 'Home', params)
}
return buildStateObject('Flat', 'Home', params)
}
if (IS_NATIVE) { if (IS_NATIVE) {
if (name === 'Search') { if (name === 'Search') {
return buildStateObject('SearchTab', 'Search', params) return buildStateObject('SearchTab', 'Search', params)
+16 -10
View File
@@ -28,23 +28,29 @@ type Layout = {
border?: boolean border?: boolean
} }
type Props = {
animate?: boolean
profiles: bsky.profile.AnyProfileView[]
size?: number
moderationOpts?: ModerationOpts
}
export function AvatarBubbles({ export function AvatarBubbles({
animate = false, animate = false,
profiles: allProfiles, profiles: allProfiles,
self = false,
size = 120, size = 120,
moderationOpts, moderationOpts,
}: Props) { }: {
animate?: boolean
profiles: bsky.profile.AnyProfileView[]
/**
* By default, when there are more than 2 profiles, the current user is
* filtered out (so you don't see yourself among your own group's members).
* Set this to `true` for cases where every passed profile should appear,
* e.g. an invite preview where the owner is meaningful regardless of viewer.
*/
self?: boolean
size?: number
moderationOpts?: ModerationOpts
}) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const profiles = const profiles =
allProfiles.length > 2 !self && allProfiles.length > 2
? allProfiles.filter(p => p.did !== currentAccount?.did) ? allProfiles.filter(p => p?.did != null && p.did !== currentAccount?.did)
: allProfiles : allProfiles
const moderations = useMemo(() => { const moderations = useMemo(() => {
if (!moderationOpts) return [] if (!moderationOpts) return []
+3 -3
View File
@@ -757,11 +757,11 @@ export function useSharedButtonTextStyles() {
} }
if (size === 'large') { if (size === 'large') {
baseStyles.push(a.text_md, a.leading_snug, a.font_medium) baseStyles.push(a.text_md, a.font_medium)
} else if (size === 'small') { } else if (size === 'small') {
baseStyles.push(a.text_sm, a.leading_snug, a.font_medium) baseStyles.push(a.text_sm, a.font_medium)
} else if (size === 'tiny') { } else if (size === 'tiny') {
baseStyles.push(a.text_xs, a.leading_snug, a.font_semi_bold) baseStyles.push(a.text_xs, a.font_semi_bold)
} }
return flatten(baseStyles) return flatten(baseStyles)
+1
View File
@@ -499,6 +499,7 @@ function TriggerClone({
accessibilityLabel={label} accessibilityLabel={label}
accessibilityHint={_(msg`The subject of the context menu`)} accessibilityHint={_(msg`The subject of the context menu`)}
accessibilityIgnoresInvertColors={false} accessibilityIgnoresInvertColors={false}
cachePolicy="none"
/> />
</Animated.View> </Animated.View>
) )
+5 -1
View File
@@ -230,7 +230,11 @@ function LightboxGallery({
style={[ style={[
a.px_4xl, a.px_4xl,
a.py_2xl, a.py_2xl,
{backgroundColor: 'rgba(0, 0, 0, 0.45)'}, {
backgroundColor: 'rgba(0, 0, 0, 0.5)',
// @ts-expect-error web only
backdropFilter: 'blur(16px)',
},
delayedFadeInAnim, delayedFadeInAnim,
]}> ]}>
<Pressable <Pressable
+62 -39
View File
@@ -1,15 +1,10 @@
import {useRef} from 'react' import {useRef} from 'react'
import { import {LayoutAnimation, ScrollView, StyleSheet, View} from 'react-native'
LayoutAnimation,
Pressable,
ScrollView,
StyleSheet,
View,
} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {BlurView} from 'expo-blur'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, platform, useTheme} from '#/alf'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
type Props = { type Props = {
@@ -37,36 +32,65 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
{paddingBottom: insets.bottom + 8}, {paddingBottom: insets.bottom + 8},
]}> ]}>
<View style={[a.mx_md, styles.altWrap]}> <View style={[a.mx_md, styles.altWrap]}>
<ScrollView <BlurView
scrollEnabled={isAltExpanded} intensity={16}
onMomentumScrollBegin={() => { tint="dark"
isMomentumScrolling.current = true style={[
}} // Tint kept over the blur so dense, text-heavy images stay
onMomentumScrollEnd={() => { // readable. On Android the blur falls back to a flat overlay, so
isMomentumScrolling.current = false // bump the opacity to keep the contrast the real blur provides
}} // elsewhere.
contentContainerStyle={[a.px_md, a.py_sm]}> platform({
<Pressable ios: {backgroundColor: 'rgba(0, 0, 0, 0.5)'},
accessibilityRole="button" android: {backgroundColor: 'rgba(0, 0, 0, 0.7)'},
accessibilityLabel={l`Expand alt text`} }),
accessibilityHint="" ]}>
onPress={() => { <ScrollView
if (isMomentumScrolling.current) return scrollEnabled={isAltExpanded}
LayoutAnimation.configureNext({ onMomentumScrollBegin={() => {
duration: 450, isMomentumScrolling.current = true
update: {type: 'spring', springDamping: 1}, }}
}) onMomentumScrollEnd={() => {
onToggleAltExpanded() isMomentumScrolling.current = false
}}> }}
<Text contentContainerStyle={[a.px_md, a.py_sm]}>
emoji <View
selectable accessibilityRole="button"
style={[a.text_sm, {color: t.palette.white}]} accessibilityLabel={l`Expand alt text`}
numberOfLines={isAltExpanded ? undefined : 3}> accessibilityHint="">
{altText} {/*
</Text> * The press handlers must live on the Text itself, not on a
</Pressable> * wrapping Pressable. Text selection is driven by the platform's
</ScrollView> * native text view long-press (RN Text on Android, UITextView on
* iOS). A parent touchable consumes that long-press before the
* selectable Text can begin a selection - on Android this prevents
* selection entirely. Keeping onPress/onLongPress on the Text lets
* the same native node own both the tap-to-expand and the
* long-press-to-select. The empty onLongPress is intentional: it
* reserves the long-press for the OS selection gesture instead of
* firing the expand toggle. RN exposes no API to arbitrate tap vs
* native selection on a single node, so this is the supported
* workaround, and it behaves consistently on both platforms.
*/}
<Text
emoji
selectable
style={[a.text_sm, {color: t.palette.white}]}
numberOfLines={isAltExpanded ? undefined : 3}
onPress={() => {
if (isMomentumScrolling.current) return
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
onToggleAltExpanded()
}}
onLongPress={() => {}}>
{altText}
</Text>
</View>
</ScrollView>
</BlurView>
</View> </View>
</View> </View>
) )
@@ -74,7 +98,6 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
altWrap: { altWrap: {
backgroundColor: 'rgba(0, 0, 0, 0.45)',
borderRadius: 12, borderRadius: 12,
overflow: 'hidden', overflow: 'hidden',
}, },
@@ -246,6 +246,7 @@ const ImageItem = ({
} }
} }
cachePolicy="memory" cachePolicy="memory"
useAppleWebpCodec
/> />
</Animated.View> </Animated.View>
</Animated.View> </Animated.View>
@@ -32,6 +32,7 @@ import Animated, {
withSpring, withSpring,
type WithSpringConfig, type WithSpringConfig,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {Image} from 'expo-image'
import * as ScreenOrientation from 'expo-screen-orientation' import * as ScreenOrientation from 'expo-screen-orientation'
import {type Dimensions} from '#/lib/media/types' import {type Dimensions} from '#/lib/media/types'
@@ -136,6 +137,9 @@ export default function ImageViewRoot({
'worklet' 'worklet'
thumbRects.set({}) thumbRects.set({})
})() })()
requestIdleCallback(() => {
void Image.clearMemoryCache()
})
}, [thumbRects]) }, [thumbRects])
useAnimatedReaction( useAnimatedReaction(
+10
View File
@@ -12,6 +12,7 @@ import {
} from '@react-navigation/native' } from '@react-navigation/native'
import {BSKY_DOWNLOAD_URL} from '#/lib/constants' import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {useGroupChatJoinIntent} from '#/lib/hooks/useIntentHandler'
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped' import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
import {useOpenLink} from '#/lib/hooks/useOpenLink' import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types' import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
@@ -19,6 +20,7 @@ import {shareUrl} from '#/lib/sharing'
import { import {
convertBskyAppUrlIfNeeded, convertBskyAppUrlIfNeeded,
createProxiedUrl, createProxiedUrl,
getChatInviteCodeFromUrl,
isBskyDownloadUrl, isBskyDownloadUrl,
isExternalUrl, isExternalUrl,
linkRequiresWarning, linkRequiresWarning,
@@ -130,6 +132,7 @@ export function useLink({
const {closeModal} = useModalControls() const {closeModal} = useModalControls()
const {linkWarningDialogControl} = useGlobalDialogsControlContext() const {linkWarningDialogControl} = useGlobalDialogsControlContext()
const openLink = useOpenLink() const openLink = useOpenLink()
const groupChatJoinIntent = useGroupChatJoinIntent()
const onPress = useCallback( const onPress = useCallback(
(e: GestureResponderEvent) => { (e: GestureResponderEvent) => {
@@ -148,6 +151,12 @@ export function useLink({
e.preventDefault() e.preventDefault()
} }
const chatInviteCode = getChatInviteCodeFromUrl(href)
if (chatInviteCode) {
groupChatJoinIntent(chatInviteCode, href)
return
}
if (requiresWarning) { if (requiresWarning) {
linkWarningDialogControl.open({ linkWarningDialogControl.open({
displayText, displayText,
@@ -228,6 +237,7 @@ export function useLink({
overridePresentation, overridePresentation,
shouldProxy, shouldProxy,
linkWarningDialogControl, linkWarningDialogControl,
groupChatJoinIntent,
], ],
) )
+61 -10
View File
@@ -1,11 +1,16 @@
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native' import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {type AppBskyFeedDefs} from '@atproto/api' import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api'
import {Trans} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {shareImageModal} from '#/lib/media/manip'
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image'
import {isGifEmbed} from '#/lib/strings/embed-player' import {isGifEmbed} from '#/lib/strings/embed-player'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, tokens, useTheme} from '#/alf'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import * as PeekMenu from '#/components/PeekMenu'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
@@ -16,9 +21,11 @@ import * as bsky from '#/types/bsky'
export function Embed({ export function Embed({
embed, embed,
style, style,
peekable = false,
}: { }: {
embed: AppBskyFeedDefs.PostView['embed'] embed: AppBskyFeedDefs.PostView['embed']
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
peekable?: boolean
}) { }) {
const e = bsky.post.parseEmbed(embed) const e = bsky.post.parseEmbed(embed)
@@ -27,13 +34,17 @@ export function Embed({
if (e.type === 'images') { if (e.type === 'images') {
return ( return (
<Outer style={style}> <Outer style={style}>
{e.view.images.map(image => ( {e.view.images.map(image =>
<ImageItem peekable ? (
key={image.thumb} <PeekableImageItem key={image.thumb} image={image} />
thumbnail={image.thumb} ) : (
alt={image.alt} <ImageItem
/> key={image.thumb}
))} thumbnail={image.thumb}
alt={image.alt}
/>
),
)}
</Outer> </Outer>
) )
} else if (e.type === 'link') { } else if (e.type === 'link') {
@@ -118,6 +129,7 @@ export function ImageItem({
contentFit="cover" contentFit="cover"
accessible={true} accessible={true}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
/> />
<MediaInsetBorder style={[a.rounded_xs]} /> <MediaInsetBorder style={[a.rounded_xs]} />
{children} {children}
@@ -156,6 +168,45 @@ export function VideoItem({
) )
} }
function PeekableImageItem({image}: {image: AppBskyEmbedImages.ViewImage}) {
const {t: l} = useLingui()
const saveImage = useSaveImageToMediaLibrary()
const aspect =
image.aspectRatio && image.aspectRatio.height > 0
? image.aspectRatio.width / image.aspectRatio.height
: undefined
return (
<PeekMenu.Root style={[a.flex_1, {maxWidth: 100}]}>
<PeekMenu.Trigger
preview={{
type: 'image',
uri: image.fullsize,
thumbUri: image.thumb,
aspectRatio: aspect && aspect > 0 ? aspect : 1,
}}
borderRadius={tokens.borderRadius.xs}>
<ImageItem thumbnail={image.thumb} alt={image.alt} />
</PeekMenu.Trigger>
<PeekMenu.Menu>
<PeekMenu.MenuItem
id="save"
onSelect={() => void saveImage(image.fullsize)}>
<PeekMenu.MenuItemIcon icon={DownloadIcon} />
<PeekMenu.MenuItemText>{l`Save image`}</PeekMenu.MenuItemText>
</PeekMenu.MenuItem>
<PeekMenu.MenuItem
id="share"
onSelect={() => void shareImageModal({uri: image.fullsize})}>
<PeekMenu.MenuItemIcon icon={ShareIcon} />
<PeekMenu.MenuItemText>{l`Share`}</PeekMenu.MenuItemText>
</PeekMenu.MenuItem>
</PeekMenu.Menu>
</PeekMenu.Root>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
altContainer: { altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)', backgroundColor: 'rgba(0, 0, 0, 0.75)',
+1
View File
@@ -0,0 +1 @@
export * from '@bsky.app/peek-menu'
@@ -0,0 +1,41 @@
import {type StyleProp, type ViewStyle} from 'react-native'
import {type AppBskyEmbedExternal} from '@atproto/api'
import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links'
import {useSession} from '#/state/session'
import {atoms as a} from '#/alf'
import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed'
import {JoinRequestEmbed} from '#/components/Post/Embed/JoinRequestEmbed'
export function ChatInviteEmbed({
code,
link,
onOpen,
style,
}: {
code: string
link: AppBskyEmbedExternal.ViewExternal
onOpen?: () => void
style?: StyleProp<ViewStyle>
}) {
const {hasSession} = useSession()
const {data, error, isPending} = useJoinLinkPreviewsQuery({
codes: [code],
hasSession,
})
const preview = data?.joinLinkPreviews[0]
if (error) {
return <ExternalEmbed link={link} onOpen={onOpen} style={style} />
}
return (
<JoinRequestEmbed
loading={isPending}
preview={preview}
style={[a.mt_sm, style]}
onOpen={onOpen}
/>
)
}
@@ -108,6 +108,7 @@ export const ExternalEmbed = ({
source={{uri: imageUri}} source={{uri: imageUri}}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
loading="lazy" loading="lazy"
useAppleWebpCodec
/> />
) : undefined} ) : undefined}
@@ -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 {InteractionManager, View} from 'react-native'
import {type AnimatedRef} from 'react-native-reanimated' import {type AnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image' import {Image} from 'expo-image'
@@ -8,6 +9,7 @@ import {Gallery} from '#/components/images/Gallery'
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid' import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
import {useLightboxControls} from '#/components/Lightbox/state' import {useLightboxControls} from '#/components/Lightbox/state'
import {type Dimensions} from '#/components/Lightbox/types' import {type Dimensions} from '#/components/Lightbox/types'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {type EmbedType} from '#/types/bsky/post' import {type EmbedType} from '#/types/bsky/post'
@@ -24,6 +26,11 @@ export function ImageEmbed({
const {images} = embed.view const {images} = embed.view
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable) const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
// ref + dims that a tap would — keeps the lightbox's return animation intact.
const singleContainerRef = useRef<AnimatedRef<any> | null>(null)
const singleDimsRef = useRef<Dimensions | null>(null)
if (images.length > 0) { if (images.length > 0) {
const items = images.map(img => ({ const items = images.map(img => ({
uri: img.fullsize, uri: img.fullsize,
@@ -59,24 +66,45 @@ export function ImageEmbed({
if (images.length === 1) { if (images.length === 1) {
const image = images[0] const image = images[0]
const aspect =
image.aspectRatio && image.aspectRatio.height > 0
? image.aspectRatio.width / image.aspectRatio.height
: undefined
const openFromSingle = () => {
if (singleContainerRef.current) {
onPress(0, [singleContainerRef.current], [singleDimsRef.current])
}
}
return ( return (
<View style={[a.mt_sm, rest.style]}> <View style={[a.mt_sm, rest.style]}>
<AutoSizedImage <ImageContextMenu
crop={ fullsizeUri={image.fullsize}
rest.viewContext === PostEmbedViewContext.ThreadHighlighted thumbUri={image.thumb}
? 'none' aspectRatio={aspect}
: rest.viewContext === borderRadius={tokens.borderRadius.md}
PostEmbedViewContext.FeedEmbedRecordWithMedia onPreviewPress={openFromSingle}>
? 'square' <AutoSizedImage
: 'constrained' crop={
} rest.viewContext === PostEmbedViewContext.ThreadHighlighted
image={image} ? 'none'
onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])} : rest.isWithinQuote
onPressIn={() => onPressIn(0)} ? 'square'
hideBadge={ : 'constrained'
rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia }
} image={image}
/> onContainerRef={ref => {
singleContainerRef.current = ref
}}
onDimsChange={dims => {
singleDimsRef.current = dims
}}
onPress={(containerRef, dims) =>
onPress(0, [containerRef], [dims])
}
onPressIn={() => onPressIn(0)}
hideBadge={rest.isWithinQuote}
/>
</ImageContextMenu>
</View> </View>
) )
} }
@@ -89,6 +117,7 @@ export function ImageEmbed({
onPress={onPress} onPress={onPress}
onPressIn={onPressIn} onPressIn={onPressIn}
viewContext={rest.viewContext} viewContext={rest.viewContext}
isWithinQuote={rest.isWithinQuote}
/> />
</View> </View>
) )
@@ -0,0 +1,271 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {type ChatBskyGroupDefs} from '@atproto/api'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {type NavigationProp} from '#/lib/routes/types'
import {sanitizeHandle} from '#/lib/strings/handles'
import {atoms as a, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {
Button,
type ButtonColor,
ButtonIcon,
ButtonText,
} from '#/components/Button'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
import {ArrowBoxRight_Stroke2_Corner3_Rounded as JoinIcon} from '#/components/icons/ArrowBoxRight'
import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {Loader} from '#/components/Loader'
import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
const JOIN_REQUEST_EMBED_HEIGHT = 152
export function JoinRequestEmbed({
loading = false,
preview,
style,
onOpen,
}: {
loading?: boolean
preview?: ChatBskyGroupDefs.JoinLinkPreviewView
style?: StyleProp<ViewStyle>
onOpen?: () => void
}) {
const t = useTheme()
if (loading) {
return (
<View
style={[
a.align_center,
a.justify_center,
a.p_lg,
a.border,
a.rounded_lg,
t.atoms.border_contrast_high,
{height: JOIN_REQUEST_EMBED_HEIGHT},
style,
]}>
<Loader size="md" fill={t.atoms.text.color} />
</View>
)
}
if (!preview) {
return (
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.p_lg,
a.gap_xs,
a.border,
a.rounded_lg,
t.atoms.border_contrast_high,
t.atoms.bg_contrast_25,
{height: JOIN_REQUEST_EMBED_HEIGHT},
style,
]}>
<WarningIcon size="md" fill={t.atoms.text_contrast_medium.color} />
<Text style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium]}>
<Trans>Chat invite link no longer available</Trans>
</Text>
</View>
)
}
return (
<JoinRequestEmbedInner preview={preview} style={style} onOpen={onOpen} />
)
}
function JoinRequestEmbedInner({
preview,
style,
onOpen,
}: {
preview: ChatBskyGroupDefs.JoinLinkPreviewView
style?: StyleProp<ViewStyle>
onOpen?: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const {groupChatJoinDialogControl, setGroupChatJoinState} = useIntentDialogs()
const ownerDisplayName = createSanitizedDisplayName(preview.owner)
const ownerHandle = sanitizeHandle(preview.owner.handle, '@')
const avatarProfiles = preview.convo?.members ?? [preview.owner]
const convoId = preview.convo?.id
const isFollowing = preview.owner.viewer?.following ?? false
const hasRequested = !convoId && preview.viewer?.requestedAt != null
let canJoin = true
let ButtonIconImage = JoinIcon
let buttonText = preview.requireApproval ? l`Request to join` : l`Join`
let buttonColor: ButtonColor = 'primary'
if (preview.enabledStatus !== 'enabled') {
canJoin = false
ButtonIconImage = WarningIcon
buttonText = l`Chat invite link no longer available`
buttonColor = 'secondary'
} else if (preview.memberCount >= preview.memberLimit) {
canJoin = false
ButtonIconImage = HandIcon
buttonText = l`This chat is full`
buttonColor = 'secondary'
} else if (preview.joinRule === 'followedByOwner' && !isFollowing) {
canJoin = false
ButtonIconImage = HandIcon
buttonText = l`Only people the chat owner follows can join`
buttonColor = 'secondary'
} else if (hasRequested) {
ButtonIconImage = CheckIcon
buttonText = l`Requested`
buttonColor = 'secondary'
}
return (
<View
style={[
a.justify_between,
a.border,
a.rounded_lg,
a.p_lg,
a.gap_lg,
t.atoms.border_contrast_high,
{height: JOIN_REQUEST_EMBED_HEIGHT},
style,
]}>
<View style={[a.flex_row, a.gap_md, a.align_center]}>
<AvatarBubbles size={56} self profiles={avatarProfiles} />
<View style={[a.flex_1]}>
<Text
emoji
style={[a.text_lg, a.font_bold, a.leading_tight, t.atoms.text]}
numberOfLines={1}>
{preview.name}
</Text>
<View
style={[a.flex_row, a.align_center, a.gap_sm, a.mt_2xs, a.mb_sm]}>
<Text
style={[
a.text_xs,
a.leading_tight,
a.font_medium,
t.atoms.text_contrast_high,
]}
allowFontScaling
numberOfLines={1}>
<Trans>Group chat</Trans>
</Text>
<Text
style={[
a.text_xs,
a.leading_tight,
a.font_medium,
t.atoms.text_contrast_high,
]}
allowFontScaling
numberOfLines={1}>
<Trans comment="The number of members in a group chat, in the format '{members}/{total} members'.">
{preview.memberCount}/{preview.memberLimit}{' '}
<Plural
value={preview.memberCount}
one="member"
other="members"
/>
</Trans>
</Text>
</View>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Text
emoji
style={[
a.flex_shrink,
a.text_sm,
a.font_medium,
a.leading_tight,
t.atoms.text,
]}
allowFontScaling
numberOfLines={1}>
<Trans comment="The group chat creator, in the format 'By {displayName}'.">
By{' '}
<Text style={[a.font_medium, t.atoms.text]}>
{ownerDisplayName}
</Text>
</Trans>
</Text>
<ProfileBadges profile={preview.owner} size="sm" />
<Text
style={[
a.flex_shrink,
a.text_sm,
a.font_medium,
a.leading_tight,
t.atoms.text_contrast_medium,
]}
allowFontScaling
numberOfLines={1}>
{ownerHandle}
</Text>
</View>
</View>
</View>
{convoId ? (
<Button
testID="openButton"
onPress={() => {
onOpen?.()
navigation.navigate('MessagesConversation', {conversation: convoId})
}}
label={l`Open group chat`}
accessibilityHint={l`Tap to open this group chat`}
size="large"
color="primary"
style={[a.w_full]}>
<ButtonText>
<Trans>Open chat</Trans>
</ButtonText>
<ButtonIcon icon={ArrowRightIcon} />
</Button>
) : (
<Button
testID="joinButton"
onPress={() => {
onOpen?.()
setGroupChatJoinState({code: preview.code})
groupChatJoinDialogControl.open()
}}
label={
preview.requireApproval
? l`Request access to group chat`
: l`Join group chat`
}
accessibilityHint={
preview.requireApproval
? l`Tap to request access to join this group chat`
: l`Tap to join this group chat immediately`
}
size="large"
color={buttonColor}
disabled={!canJoin}
style={[a.w_full]}>
<ButtonIcon icon={ButtonIconImage} />
<ButtonText>{buttonText}</ButtonText>
</Button>
)}
</View>
)
}
@@ -166,6 +166,7 @@ export const StandardSiteEmbed = ({
source={{uri: imageUri}} source={{uri: imageUri}}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
loading="lazy" loading="lazy"
useAppleWebpCodec
/> />
) : undefined} ) : undefined}
+17
View File
@@ -12,6 +12,7 @@ import {Trans} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {getChatInviteCodeFromUrl} from '#/lib/strings/url-helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {unstableCacheProfileView} from '#/state/queries/profile' import {unstableCacheProfileView} from '#/state/queries/profile'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
@@ -33,6 +34,7 @@ import {
type EmbedType, type EmbedType,
parseEmbed, parseEmbed,
} from '#/types/bsky/post' } from '#/types/bsky/post'
import {ChatInviteEmbed} from './ChatInviteEmbed'
import {ExternalEmbed} from './ExternalEmbed' import {ExternalEmbed} from './ExternalEmbed'
import {ModeratedFeedEmbed} from './FeedEmbed' import {ModeratedFeedEmbed} from './FeedEmbed'
import {ImageEmbed} from './ImageEmbed' import {ImageEmbed} from './ImageEmbed'
@@ -110,6 +112,21 @@ function MediaEmbed({
</ContentHider> </ContentHider>
) )
} }
const chatInviteCode = getChatInviteCodeFromUrl(embed.view.external.uri)
if (chatInviteCode) {
return (
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
activeStyle={[a.mt_sm]}>
<ChatInviteEmbed
code={chatInviteCode}
link={embed.view.external}
onOpen={rest.onOpen}
style={rest.style}
/>
</ContentHider>
)
}
return ( return (
<ContentHider <ContentHider
modui={rest.moderation?.ui('contentMedia')} modui={rest.moderation?.ui('contentMedia')}
+3 -1
View File
@@ -224,6 +224,7 @@ export function Basic({
cancelButtonCta, cancelButtonCta,
confirmButtonCta, confirmButtonCta,
onConfirm, onConfirm,
onClose,
confirmButtonColor, confirmButtonColor,
showCancel = true, showCancel = true,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
@@ -240,11 +241,12 @@ export function Basic({
* should NOT close the dialog as a side effect of this method. * should NOT close the dialog as a side effect of this method.
*/ */
onConfirm: (e: GestureResponderEvent) => void onConfirm: (e: GestureResponderEvent) => void
onClose?: () => void
confirmButtonColor?: ButtonColor confirmButtonColor?: ButtonColor
showCancel?: boolean showCancel?: boolean
}>) { }>) {
return ( return (
<Outer control={control} testID="confirmModal"> <Outer control={control} testID="confirmModal" onClose={onClose}>
<Content> <Content>
<TitleText>{title}</TitleText> <TitleText>{title}</TitleText>
{description && <DescriptionText>{description}</DescriptionText>} {description && <DescriptionText>{description}</DescriptionText>}
+1 -1
View File
@@ -66,7 +66,7 @@ export function RichText({
} }
}, [value]) }, [value])
const plainStyles = [a.leading_snug, style] const plainStyles = style
const interactiveStyles = [plainStyles, interactiveStyle] const interactiveStyles = [plainStyles, interactiveStyle]
const {text, facets} = richText const {text, facets} = richText
+4 -2
View File
@@ -8,8 +8,10 @@ import {
type UninheritableButtonProps, type UninheritableButtonProps,
} from '#/components/Button' } from '#/components/Button'
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck' import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' CircleInfo_Stroke2_Corner0_Rounded as CircleInfo,
CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon,
} from '#/components/icons/CircleInfo'
import {type Props as SVGIconProps} from '#/components/icons/common' import {type Props as SVGIconProps} from '#/components/icons/common'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {dismiss} from '#/components/Toast/sonner' import {dismiss} from '#/components/Toast/sonner'
+1
View File
@@ -31,6 +31,7 @@ export function Text({
[ [
a.text_sm, a.text_sm,
t.atoms.text, t.atoms.text,
a.leading_snug,
web(numberOfLines === 1 && numberOfLinesClippingFix), web(numberOfLines === 1 && numberOfLinesClippingFix),
style, style,
], ],
@@ -60,6 +60,7 @@ export function FindContactsBannerNUX() {
a.self_end, a.self_end,
a.mt_sm, a.mt_sm,
]} ]}
useAppleWebpCodec
/> />
<View style={[a.flex_1, a.justify_center, a.py_xl, a.pr_5xl]}> <View style={[a.flex_1, a.justify_center, a.py_xl, a.pr_5xl]}>
<Text <Text
@@ -27,6 +27,7 @@ export function ContactsHeroImage() {
alt={_( alt={_(
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`, msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
)} )}
useAppleWebpCodec
/> />
</View> </View>
) )
@@ -113,6 +113,7 @@ export function ActivitySubscriptionsNUX() {
alt={_( alt={_(
msg`A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature.`, msg`A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature.`,
)} )}
useAppleWebpCodec
/> />
</View> </View>
</View> </View>
@@ -124,6 +124,7 @@ export function BookmarksAnnouncement() {
'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.', 'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.',
}), }),
)} )}
useAppleWebpCodec
/> />
</View> </View>
</View> </View>
@@ -101,6 +101,7 @@ export function DraftsAnnouncement() {
'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.', 'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.',
}), }),
)} )}
useAppleWebpCodec
/> />
</View> </View>
<View style={[a.align_center, a.px_xl, a.pt_xl, a.gap_2xl, a.pb_sm]}> <View style={[a.align_center, a.px_xl, a.pt_xl, a.gap_2xl, a.pb_sm]}>
@@ -78,6 +78,7 @@ export function FindContactsAnnouncement() {
alt={_( alt={_(
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`, msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
)} )}
useAppleWebpCodec
/> />
</View> </View>
</View> </View>
@@ -85,6 +85,7 @@ export function InitialVerificationAnnouncement() {
alt={_( alt={_(
msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`, msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`,
)} )}
useAppleWebpCodec
/> />
</View> </View>
@@ -119,6 +120,7 @@ export function InitialVerificationAnnouncement() {
alt={_( alt={_(
msg`An mockup of a iPhone showing the Bluesky app open to the profile of a verified user with a blue checkmark next to their display name.`, msg`An mockup of a iPhone showing the Bluesky app open to the profile of a verified user with a blue checkmark next to their display name.`,
)} )}
useAppleWebpCodec
/> />
</View> </View>
@@ -150,6 +150,7 @@ export function LiveNowBetaDialog() {
'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.', 'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.',
}), }),
)} )}
useAppleWebpCodec
/> />
</View> </View>
</View> </View>
+1 -1
View File
@@ -219,7 +219,7 @@ export function AddMembersFlow({
} }
} }
if (searchText === '') { if (searchText === '' && _items.length > 0) {
_items.unshift({ _items.unshift({
type: 'label', type: 'label',
key: 'suggested', key: 'suggested',
+6 -1
View File
@@ -33,14 +33,19 @@ export const AfterReportDialog = memo(function BlockOrDeleteDialogInner({
control, control,
params, params,
currentScreen, currentScreen,
onClose,
}: { }: {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
params: ReportDialogParams params: ReportDialogParams
currentScreen: 'list' | 'conversation' currentScreen: 'list' | 'conversation'
onClose?: () => void
}): React.ReactNode { }): React.ReactNode {
const {t: l} = useLingui() const {t: l} = useLingui()
return ( return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> <Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner <Dialog.ScrollableInner
label={l`Would you like to block this user and/or delete this conversation?`} label={l`Would you like to block this user and/or delete this conversation?`}
+1
View File
@@ -316,6 +316,7 @@ export function InitiateChatFlow({
if ( if (
searchText === '' && searchText === '' &&
_items.length > 0 &&
(chatState === ChatState.NEW_CHAT || (chatState === ChatState.NEW_CHAT ||
chatState === ChatState.NEW_GROUP_CHAT) chatState === ChatState.NEW_GROUP_CHAT)
) { ) {
+64 -112
View File
@@ -1,5 +1,5 @@
import {memo, useCallback} from 'react' import {memo, useCallback} from 'react'
import {LayoutAnimation, Platform} from 'react-native' import {Platform} from 'react-native'
import * as Clipboard from 'expo-clipboard' import * as Clipboard from 'expo-clipboard'
import { import {
type ChatBskyConvoDefs, type ChatBskyConvoDefs,
@@ -7,26 +7,21 @@ import {
RichText, RichText,
} from '@atproto/api' } from '@atproto/api'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {richTextToString} from '#/lib/strings/rich-text-helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {useConvoActive} from '#/state/messages/convo' import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import * as ContextMenu from '#/components/ContextMenu' import * as ContextMenu from '#/components/ContextMenu'
import {type TriggerProps} from '#/components/ContextMenu/types' import {type TriggerProps} from '#/components/ContextMenu/types'
import {AfterReportDialog} from '#/components/dms/AfterReportDialog' import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language' import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
@@ -48,11 +43,8 @@ export let MessageContextMenu = ({
const {t: l, i18n} = useLingui() const {t: l, i18n} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const queryClient = useQueryClient()
const convo = useConvoActive() const convo = useConvoActive()
const deleteControl = usePromptControl() const {openDeleteMessage, openReportMessage} = useMessageDialogs()
const reportControl = usePromptControl()
const blockOrDeleteControl = usePromptControl()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const translate = useGoogleTranslate() const translate = useGoogleTranslate()
@@ -93,14 +85,6 @@ export let MessageContextMenu = ({
}) })
}, [ax, langPrefs.primaryLanguage, message.text, translate]) }, [ax, langPrefs.primaryLanguage, message.text, translate])
const onDelete = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo
.deleteMessage(message.id)
.then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
.catch(() => Toast.show(l`Failed to delete message`))
}, [l, convo, message.id])
const onEmojiSelect = useCallback( const onEmojiSelect = useCallback(
(emoji: string) => { (emoji: string) => {
if ( if (
@@ -128,104 +112,72 @@ export let MessageContextMenu = ({
const sender = senderProfile const sender = senderProfile
return ( return (
<> <ContextMenu.Root>
<ContextMenu.Root> {IS_NATIVE && reactionsAvailable && (
{IS_NATIVE && reactionsAvailable && ( <ContextMenu.AuxiliaryView
<ContextMenu.AuxiliaryView
align={isFromSelf ? 'right' : 'left'}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
<EmojiReactionPicker
message={message}
onEmojiSelect={onEmojiSelect}
/>
</ContextMenu.AuxiliaryView>
)}
<ContextMenu.Trigger
label={l`Message options`}
contentLabel={l`Message from @${
sender?.handle ?? 'unknown' // should always be defined
}: ${message.text}`}>
{children}
</ContextMenu.Trigger>
<ContextMenu.Outer
align={isFromSelf ? 'right' : 'left'} align={isFromSelf ? 'right' : 'left'}
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
timeStyle: 'short',
})}`}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}> style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
{message.text.length > 0 && ( <EmojiReactionPicker
<> message={message}
<ContextMenu.Item onEmojiSelect={onEmojiSelect}
testID="messageDropdownTranslateBtn" />
label={l`Translate`} </ContextMenu.AuxiliaryView>
onPress={onPressTranslateMessage}> )}
<ContextMenu.ItemIcon icon={LanguageIcon} position="left" />
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText> <ContextMenu.Trigger
</ContextMenu.Item> label={l`Message options`}
<ContextMenu.Item contentLabel={l`Message from @${
testID="messageDropdownCopyBtn" sender?.handle ?? 'unknown' // should always be defined
label={l`Copy message text`} }: ${message.text}`}>
onPress={onCopyMessage}> {children}
<ContextMenu.ItemIcon icon={ClipboardIcon} position="left" /> </ContextMenu.Trigger>
<ContextMenu.ItemText>
{l`Copy message text`} <ContextMenu.Outer
</ContextMenu.ItemText> align={isFromSelf ? 'right' : 'left'}
</ContextMenu.Item> label={l`Sent at ${i18n.date(new Date(message.sentAt), {
</> timeStyle: 'short',
)} })}`}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
{message.text.length > 0 && (
<>
<ContextMenu.Item
testID="messageDropdownTranslateBtn"
label={l`Translate`}
onPress={onPressTranslateMessage}>
<ContextMenu.ItemIcon icon={LanguageIcon} position="left" />
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
</ContextMenu.Item>
<ContextMenu.Item
testID="messageDropdownCopyBtn"
label={l`Copy message text`}
onPress={onCopyMessage}>
<ContextMenu.ItemIcon icon={ClipboardIcon} position="left" />
<ContextMenu.ItemText>
{l`Copy message text`}
</ContextMenu.ItemText>
</ContextMenu.Item>
</>
)}
<ContextMenu.Item
destructive
testID="messageDropdownDeleteBtn"
label={l`Delete message for me`}
onPress={() => openDeleteMessage(message)}>
<ContextMenu.ItemIcon icon={TrashIcon} position="left" />
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
</ContextMenu.Item>
{!isFromSelf && (
<ContextMenu.Item <ContextMenu.Item
destructive destructive
testID="messageDropdownDeleteBtn" testID="messageDropdownReportBtn"
label={l`Delete message for me`} label={l`Report message`}
onPress={() => deleteControl.open()}> onPress={() => openReportMessage(message, senderProfile)}>
<ContextMenu.ItemIcon icon={TrashIcon} position="left" /> <ContextMenu.ItemIcon icon={FlagIcon} position="left" />
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText> <ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
</ContextMenu.Item> </ContextMenu.Item>
{!isFromSelf && ( )}
<ContextMenu.Item </ContextMenu.Outer>
destructive </ContextMenu.Root>
testID="messageDropdownReportBtn"
label={l`Report message`}
onPress={() => reportControl.open()}>
<ContextMenu.ItemIcon icon={FlagIcon} position="left" />
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
</ContextMenu.Item>
)}
</ContextMenu.Outer>
</ContextMenu.Root>
<ReportDialog
control={reportControl}
subject={{
view: 'message',
convoId: convo.convo.view.id,
message,
}}
onAfterSubmit={() => {
if (sender) {
unstableCacheProfileView(queryClient, sender)
}
blockOrDeleteControl.open()
}}
/>
<AfterReportDialog
control={blockOrDeleteControl}
currentScreen="conversation"
params={{
convoId: convo.convo.view.id,
did: message.sender.did,
}}
/>
<Prompt.Basic
control={deleteControl}
title={l`Delete message`}
description={l`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants.`}
confirmButtonCta={l`Delete`}
confirmButtonColor="negative"
onConfirm={onDelete}
/>
</>
) )
} }
MessageContextMenu = memo(MessageContextMenu) MessageContextMenu = memo(MessageContextMenu)
+8 -39
View File
@@ -40,8 +40,8 @@ import {useSession} from '#/state/session'
import {atoms as a, native, platform, useTheme} from '#/alf' import {atoms as a, native, platform, useTheme} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography' import {isOnlyEmoji} from '#/alf/typography'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {InlineLinkText, Link} from '#/components/Link' import {InlineLinkText, Link} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
@@ -49,7 +49,7 @@ import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {DateDivider} from './DateDivider' import {DateDivider} from './DateDivider'
import {MessageItemEmbed} from './MessageItemEmbed' import {MessageItemEmbed} from './MessageItemEmbed'
import {ReactionsDialog} from './ReactionsDialog' import {groupReactions} from './ReactionsDialog'
import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util' import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util'
const AVATAR_SIZE = 28 const AVATAR_SIZE = 28
@@ -118,7 +118,7 @@ let MessageItem = ({
const {message} = item const {message} = item
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did)) const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
const reactionsControl = useDialogControl() const {openReactions} = useMessageDialogs()
const isPending = item.type === 'pending-message' const isPending = item.type === 'pending-message'
@@ -243,34 +243,10 @@ let MessageItem = ({
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} /> <ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
) )
const groupedReactions = useMemo(() => { const groupedReactions = useMemo(
const reactions = message.reactions ?? [] () => groupReactions(message.reactions),
const grouped = new Map< [message.reactions],
string, )
{
key: string
value: string
senders: ChatBskyConvoDefs.ReactionViewSender[]
count: number
}
>()
for (const reaction of reactions) {
if (!reaction) continue
const existing = grouped.get(reaction.value)
if (existing) {
existing.senders.push(reaction.sender)
existing.count++
} else {
grouped.set(reaction.value, {
key: reaction.value,
value: reaction.value,
senders: [reaction.sender],
count: 1,
})
}
}
return Array.from(grouped.values())
}, [message.reactions])
const reactions = useMemo(() => message.reactions ?? [], [message.reactions]) const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
@@ -336,7 +312,7 @@ let MessageItem = ({
transform: [{translateY: -8}], transform: [{translateY: -8}],
}, },
]} ]}
onPress={isGroupChat ? reactionsControl.open : undefined}> onPress={isGroupChat ? () => openReactions(message) : undefined}>
{groupedReactions.map(group => ( {groupedReactions.map(group => (
<Animated.View <Animated.View
entering={native(ZoomIn.springify(200).delay(400))} entering={native(ZoomIn.springify(200).delay(400))}
@@ -377,13 +353,6 @@ let MessageItem = ({
</Pressable> </Pressable>
</View> </View>
) : null} ) : null}
<ReactionsDialog
control={reactionsControl}
relatedProfiles={relatedProfiles}
message={message}
reactions={message.reactions}
groupedReactions={groupedReactions}
/>
</LayoutAnimationConfig> </LayoutAnimationConfig>
) )
+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 { import {
LayoutAnimation, LayoutAnimation,
Pressable, Pressable,
@@ -37,14 +37,12 @@ export function ReactionsDialog({
control, control,
relatedProfiles, relatedProfiles,
message, message,
reactions, onClose,
groupedReactions,
}: { }: {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
reactions?: ChatBskyConvoDefs.ReactionView[] onClose?: () => void
groupedReactions?: Reaction[]
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -54,6 +52,9 @@ export function ReactionsDialog({
const [selected, setSelected] = useState('all') const [selected, setSelected] = useState('all')
const reactions = message.reactions
const groupedReactions = useMemo(() => groupReactions(reactions), [reactions])
const filteredReactions = reactions?.filter( const filteredReactions = reactions?.filter(
r => selected === 'all' || r.value === selected, r => selected === 'all' || r.value === selected,
) )
@@ -78,7 +79,10 @@ export function ReactionsDialog({
return ( return (
<Dialog.Outer <Dialog.Outer
control={control} control={control}
onClose={() => setSelected('all')} onClose={() => {
setSelected('all')
onClose?.()
}}
nativeOptions={{ nativeOptions={{
preventExpansion: true, preventExpansion: true,
minHeight: screenHeight / 2, minHeight: screenHeight / 2,
@@ -388,3 +392,25 @@ function ReactionTab({
</Pressable> </Pressable>
) )
} }
export function groupReactions(
reactions: ChatBskyConvoDefs.ReactionView[] | undefined,
): Reaction[] {
const grouped = new Map<string, Reaction>()
for (const reaction of reactions ?? []) {
if (!reaction) continue
const existing = grouped.get(reaction.value)
if (existing) {
existing.senders.push(reaction.sender)
existing.count++
} else {
grouped.set(reaction.value, {
key: reaction.value,
value: reaction.value,
senders: [reaction.sender],
count: 1,
})
}
}
return Array.from(grouped.values())
}
@@ -1,22 +1,45 @@
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
import * as Linking from 'expo-linking'
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
import { import {
createStarterPackLinkFromAndroidReferrer, createStarterPackLinkFromAndroidReferrer,
httpStarterPackUriToAtUri, httpStarterPackUriToAtUri,
} from '#/lib/strings/starter-pack' } from '#/lib/strings/starter-pack'
import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers'
import {useHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' import {useHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
import {useSetActiveStarterPack} from '#/state/shell/starter-pack' import {
useSetActiveLanding,
useSetActiveStarterPack,
} from '#/state/shell/landing'
import {IS_ANDROID} from '#/env' import {IS_ANDROID} from '#/env'
import {Referrer, SharedPrefs} from '../../../modules/expo-bluesky-swiss-army' import {Referrer, SharedPrefs} from '../../../modules/expo-bluesky-swiss-army'
export function useStarterPackEntry() { export function useLandingEntry() {
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false)
const setActiveStarterPack = useSetActiveStarterPack() const setActiveStarterPack = useSetActiveStarterPack()
const setActiveLanding = useSetActiveLanding()
const hasCheckedForStarterPack = useHasCheckedForStarterPack() const hasCheckedForStarterPack = useHasCheckedForStarterPack()
useEffect(() => { useEffect(() => {
if (ready) return if (ready) return
// Check for group chat invite link from the initial deep link URL
const linkingUrl = Linking.getLinkingURL()
if (linkingUrl) {
const urlp = parseLinkingUrl(linkingUrl)
const chatInviteMatch = urlp.pathname.match(CHAT_INVITE_CODE_REGEX)
if (chatInviteMatch) {
setActiveLanding({
type: 'groupchat',
uri: linkingUrl,
code: chatInviteMatch[1],
})
setReady(true)
return
}
}
// On Android, we cannot clear the referral link. It gets stored for 90 days and all we can do is query for it. So, // On Android, we cannot clear the referral link. It gets stored for 90 days and all we can do is query for it. So,
// let's just ensure we never check again after the first time. // let's just ensure we never check again after the first time.
if (hasCheckedForStarterPack) { if (hasCheckedForStarterPack) {
@@ -29,7 +52,8 @@ export function useStarterPackEntry() {
setReady(true) setReady(true)
}, 500) }, 500)
;(async () => { void (async () => {
// Check for starter pack
let uri: string | null | undefined let uri: string | null | undefined
if (IS_ANDROID) { if (IS_ANDROID) {
@@ -58,7 +82,7 @@ export function useStarterPackEntry() {
return () => { return () => {
clearTimeout(timeout) clearTimeout(timeout)
} }
}, [ready, setActiveStarterPack, hasCheckedForStarterPack]) }, [ready, setActiveStarterPack, setActiveLanding, hasCheckedForStarterPack])
return ready return ready
} }
@@ -1,25 +1,27 @@
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
import {httpStarterPackUriToAtUri} from '#/lib/strings/starter-pack' import {httpStarterPackUriToAtUri} from '#/lib/strings/starter-pack'
import {useSetActiveStarterPack} from '#/state/shell/starter-pack' import {useSetActiveStarterPack} from '#/state/shell/landing'
export function useStarterPackEntry() { export function useLandingEntry() {
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false)
const setActiveStarterPack = useSetActiveStarterPack() const setActiveStarterPack = useSetActiveStarterPack()
useEffect(() => { useEffect(() => {
const href = window.location.href const href = window.location.href
const atUri = httpStarterPackUriToAtUri(href) const url = new URL(href)
// Check for starter pack
const atUri = httpStarterPackUriToAtUri(href)
if (atUri) { if (atUri) {
const url = new URL(href)
// Determines if an App Clip is loading this landing page // Determines if an App Clip is loading this landing page
const isClip = url.searchParams.get('clip') === 'true' const isClip = url.searchParams.get('clip') === 'true'
setActiveStarterPack({ setActiveStarterPack({
uri: atUri, uri: atUri,
isClip, isClip,
}) })
setReady(true)
return
} }
setReady(true) setReady(true)
+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 Svg, {Path} from 'react-native-svg'
import {type Props, useCommonSVGProps} from '#/components/icons/common' import {type Props, useCommonSVGProps} from '#/components/icons/common'
export type IconWithSvgMeta = ForwardRefExoticComponent<
Props & RefAttributes<Svg>
> & {
svgPaths: string[]
svgViewBox: string
svgStrokeWidth: number
}
export const IconTemplate_Stroke2_Corner0_Rounded = forwardRef( export const IconTemplate_Stroke2_Corner0_Rounded = forwardRef(
function LogoImpl(props: Props, ref) { function LogoImpl(props: Props, ref) {
const {fill, size, style, ...rest} = useCommonSVGProps(props) const {fill, size, style, ...rest} = useCommonSVGProps(props)
@@ -41,7 +53,7 @@ export function createSinglePathSVG({
strokeLinecap?: 'butt' | 'round' | 'square' strokeLinecap?: 'butt' | 'round' | 'square'
strokeLinejoin?: 'miter' | 'round' | 'bevel' strokeLinejoin?: 'miter' | 'round' | 'bevel'
}) { }) {
return forwardRef<Svg, Props>(function LogoImpl(props, ref) { const Icon = forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
const hasStroke = strokeWidth > 0 const hasStroke = strokeWidth > 0
@@ -68,7 +80,11 @@ export function createSinglePathSVG({
/> />
</Svg> </Svg>
) )
}) }) as IconWithSvgMeta
Icon.svgPaths = [path]
Icon.svgViewBox = viewBox || '0 0 24 24'
Icon.svgStrokeWidth = strokeWidth
return Icon
} }
export function createMultiPathSVG({ export function createMultiPathSVG({
@@ -78,7 +94,7 @@ export function createMultiPathSVG({
paths: string[] paths: string[]
viewBox?: string viewBox?: string
}) { }) {
return forwardRef<Svg, Props>(function LogoImpl(props, ref) { const Icon = forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
return ( return (
@@ -102,5 +118,9 @@ export function createMultiPathSVG({
))} ))}
</Svg> </Svg>
) )
}) }) as IconWithSvgMeta
Icon.svgPaths = paths
Icon.svgViewBox = viewBox || '0 0 24 24'
Icon.svgStrokeWidth = 0
return Icon
} }
+16 -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 {type DimensionValue, Pressable, View} from 'react-native'
import Animated, { import Animated, {
type AnimatedRef, type AnimatedRef,
@@ -69,6 +69,8 @@ export function AutoSizedImage({
onPress, onPress,
onLongPress, onLongPress,
onPressIn, onPressIn,
onContainerRef,
onDimsChange,
}: { }: {
image: AppBskyEmbedImages.ViewImage image: AppBskyEmbedImages.ViewImage
crop?: 'none' | 'square' | 'constrained' crop?: 'none' | 'square' | 'constrained'
@@ -79,6 +81,11 @@ export function AutoSizedImage({
) => void ) => void
onLongPress?: () => void onLongPress?: () => void
onPressIn?: () => void onPressIn?: () => void
/** Fires once with the internal container ref so a parent can drive its
* own lightbox-return animation without waiting for an `onPress`. */
onContainerRef?: (ref: AnimatedRef<any>) => void
/** Fires when the underlying image reports its natural dimensions. */
onDimsChange?: (dims: Dimensions) => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@@ -86,6 +93,10 @@ export function AutoSizedImage({
const containerRef = useAnimatedRef() const containerRef = useAnimatedRef()
const fetchedDimsRef = useRef<{width: number; height: number} | null>(null) const fetchedDimsRef = useRef<{width: number; height: number} | null>(null)
useEffect(() => {
onContainerRef?.(containerRef)
}, [containerRef, onContainerRef])
let aspectRatio: number | undefined let aspectRatio: number | undefined
const dims = image.aspectRatio const dims = image.aspectRatio
if (dims) { if (dims) {
@@ -122,13 +133,16 @@ export function AutoSizedImage({
accessibilityHint="" accessibilityHint=""
onLoad={e => { onLoad={e => {
if (!isContain) { if (!isContain) {
fetchedDimsRef.current = { const dims = {
width: e.source.width, width: e.source.width,
height: e.source.height, height: e.source.height,
} }
fetchedDimsRef.current = dims
onDimsChange?.(dims)
} }
}} }}
loading="lazy" loading="lazy"
useAppleWebpCodec
/> />
<MediaInsetBorder /> <MediaInsetBorder />
+160 -139
View File
@@ -24,7 +24,7 @@ import {mergeRefs} from '#/lib/merge-refs'
import {useA11y} from '#/state/a11y' import {useA11y} from '#/state/a11y'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
import {AutoSizedImage} from '#/components/images/AutoSizedImage' import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import { import {
@@ -36,10 +36,11 @@ import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandle
import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers' import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
import {getAspectRatio} from '#/components/images/Gallery/utils' import {getAspectRatio} from '#/components/images/Gallery/utils'
import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env' import {IS_ANDROID, IS_WEB} from '#/env'
export * from './const' export * from './const'
export * from './maybeApplyGalleryOffsetStyles' export * from './maybeApplyGalleryOffsetStyles'
@@ -53,6 +54,7 @@ interface GalleryProps {
) => void ) => void
onPressIn?: (index: number) => void onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
} }
const Context = createContext<{ const Context = createContext<{
@@ -96,6 +98,7 @@ export function Gallery({
onPress, onPress,
onPressIn, onPressIn,
viewContext, viewContext,
isWithinQuote,
}: GalleryProps) { }: GalleryProps) {
const {t: l} = useLingui() const {t: l} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
@@ -103,14 +106,21 @@ export function Gallery({
const largeAltBadge = useLargeAltBadgeEnabled() const largeAltBadge = useLargeAltBadgeEnabled()
const bps = useBreakpoints() const bps = useBreakpoints()
const window = useWindowDimensions() const window = useWindowDimensions()
const isWithinQuote =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage
const hideBadges = isWithinQuote const hideBadges = isWithinQuote
const contentHeight = useMemo(() => { const contentHeight = useMemo(() => {
if (isWithinChat) { if (isWithinChat) {
return 120 return 120
} }
if (isWithinQuote) {
if (bps.gtMobile) {
return 220
} else if (bps.gtPhone) {
return 190
} else {
return 150
}
}
if (bps.gtMobile) { if (bps.gtMobile) {
return 300 return 300
} else if (bps.gtPhone) { } else if (bps.gtPhone) {
@@ -118,7 +128,7 @@ export function Gallery({
} else { } else {
return 200 return 200
} }
}, [bps, isWithinChat]) }, [bps, isWithinChat, isWithinQuote])
/* /*
* Container overflow styles * Container overflow styles
@@ -219,7 +229,7 @@ export function Gallery({
crop={ crop={
viewContext === PostEmbedViewContext.ThreadHighlighted viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none' ? 'none'
: viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia : isWithinQuote
? 'square' ? 'square'
: 'constrained' : 'constrained'
} }
@@ -228,9 +238,7 @@ export function Gallery({
onPress?.(index, [containerRef], [dims]) onPress?.(index, [containerRef], [dims])
} }
onPressIn={() => onPressIn?.(index)} onPressIn={() => onPressIn?.(index)}
hideBadge={ hideBadge={isWithinQuote}
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
}
/> />
))} ))}
</View> </View>
@@ -256,6 +264,9 @@ export function Gallery({
aria-label={l`Image gallery, ${images.length} images`} aria-label={l`Image gallery, ${images.length} images`}
horizontal horizontal
pagingEnabled={false} 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} showsHorizontalScrollIndicator={false}
directionalLockEnabled directionalLockEnabled
nestedScrollEnabled nestedScrollEnabled
@@ -264,6 +275,21 @@ export function Gallery({
data={images} data={images}
keyExtractor={(item, index) => item.thumb + index} keyExtractor={(item, index) => item.thumb + index}
renderItem={({item, index}) => { renderItem={({item, index}) => {
const openLightboxAtIndex = onPress
? () => {
ax.metric('post:gallery:openLightbox', {
fromImage: index + 1, // convert to 1-based index for easier analysis
totalImages: images.length,
})
const refs: AnimatedRef<any>[] = []
const dims: (Dimensions | null)[] = []
for (let i = 0; i < images.length; i++) {
refs.push(containerRefsRef.current.get(i)!)
dims.push(thumbDimsRef.current.get(i) ?? null)
}
onPress(index, refs, dims)
}
: undefined
return ( return (
<GalleryImage <GalleryImage
hideBadges={hideBadges} hideBadges={hideBadges}
@@ -288,24 +314,9 @@ export function Gallery({
onThumbDims={(i, dims) => { onThumbDims={(i, dims) => {
thumbDimsRef.current.set(i, dims) thumbDimsRef.current.set(i, dims)
}} }}
onPress={ onPress={openLightboxAtIndex}
onPress
? () => {
ax.metric('post:gallery:openLightbox', {
fromImage: index + 1, // convert to 1-based index for easier analysis
totalImages: images.length,
})
const refs: AnimatedRef<any>[] = []
const dims: (Dimensions | null)[] = []
for (let i = 0; i < images.length; i++) {
refs.push(containerRefsRef.current.get(i)!)
dims.push(thumbDimsRef.current.get(i) ?? null)
}
onPress(index, refs, dims)
}
: undefined
}
onPressIn={onPressIn ? () => onPressIn(index) : undefined} onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onPreviewPress={openLightboxAtIndex}
/> />
) )
}} }}
@@ -378,6 +389,7 @@ function GalleryImage({
onThumbDims, onThumbDims,
onPress, onPress,
onPressIn, onPressIn,
onPreviewPress,
}: { }: {
contentHeight: number contentHeight: number
image: AppBskyEmbedImages.ViewImage image: AppBskyEmbedImages.ViewImage
@@ -391,6 +403,7 @@ function GalleryImage({
onThumbDims: (index: number, dims: Dimensions) => void onThumbDims: (index: number, dims: Dimensions) => void
onPress?: () => void onPress?: () => void
onPressIn?: () => void onPressIn?: () => void
onPreviewPress?: () => void
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -416,124 +429,132 @@ function GalleryImage({
collapsable={false} collapsable={false}
aria-roledescription={l`slide`} aria-roledescription={l`slide`}
aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}> aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}>
<Pressable <ImageContextMenu
ref={itemRef} fullsizeUri={image.fullsize}
tabIndex={index === 0 ? 0 : -1} thumbUri={image.thumb}
onPress={onPress} aspectRatio={aspectRatio}
onPressIn={onPressIn} borderRadius={tokens.borderRadius.md}
onFocus={() => setFocused(true)} onPreviewPress={onPreviewPress}>
onBlur={() => setFocused(false)} <Pressable
accessibilityRole="button" ref={itemRef}
accessibilityLabel={image.alt || l`Image ${index + 1}`} tabIndex={index === 0 ? 0 : -1}
accessibilityHint={l`Opens full image`} onPress={onPress}
android_ripple={{ onPressIn={onPressIn}
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2), onFocus={() => setFocused(true)}
foreground: true, onBlur={() => setFocused(false)}
}} accessibilityRole="button"
style={({pressed}) => [ accessibilityLabel={image.alt || l`Image ${index + 1}`}
a.rounded_md, accessibilityHint={l`Opens full image`}
a.overflow_hidden, android_ripple={{
t.atoms.bg_contrast_25, color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
web([ foreground: true,
{
cursor: 'inherit',
outline: 0,
border: 0,
},
a.transition_transform,
{transitionDuration: '200ms'},
pressed && {transform: [{scale: 0.99}]},
]),
]}>
<Image
source={{uri: image.thumb}}
contentFit="cover"
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
loading={index === 0 ? 'eager' : 'lazy'}
style={[dims]}
onLoad={e => {
const ar = getAspectRatio(e.source)
if (ar && ar !== aspectRatio) {
setAspectRatio(ar)
}
onThumbDims(index, {
width: e.source.width,
height: e.source.height,
})
}} }}
/> style={({pressed}) => [
a.rounded_md,
{(hasAlt || isCropped) && !hideBadges ? ( a.overflow_hidden,
<View t.atoms.bg_contrast_25,
accessible={false} web([
style={[
a.absolute,
a.flex_row,
{ {
bottom: a.p_xs.padding, cursor: 'inherit',
right: a.p_xs.padding, outline: 0,
gap: 3, border: 0,
}, },
largeAltBadge && { a.transition_transform,
gap: 4, {transitionDuration: '200ms'},
}, pressed && {transform: [{scale: 0.99}]},
]}> ]),
{isCropped && ( ]}>
<View <Image
style={[ source={{uri: image.thumb}}
a.rounded_sm, contentFit="cover"
a.p_xs, accessible={true}
t.atoms.bg_contrast_25, accessibilityLabel={image.alt}
{ accessibilityHint=""
opacity: 0.8, accessibilityIgnoresInvertColors
}, loading={index === 0 ? 'eager' : 'lazy'}
largeAltBadge && { style={[dims]}
padding: 6, onLoad={e => {
}, const ar = getAspectRatio(e.source)
]}> if (ar && ar !== aspectRatio) {
<Fullscreen setAspectRatio(ar)
fill={t.atoms.text_contrast_high.color} }
width={largeAltBadge ? 18 : 12} onThumbDims(index, {
/> width: e.source.width,
</View> height: e.source.height,
)} })
{hasAlt && ( }}
<View useAppleWebpCodec
style={[ />
a.justify_center,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Text
style={[
a.font_bold,
largeAltBadge ? a.text_xs : {fontSize: 8},
]}>
<Trans>ALT</Trans>
</Text>
</View>
)}
</View>
) : null}
<MediaInsetBorder {(hasAlt || isCropped) && !hideBadges ? (
style={ <View
focused && { accessible={false}
borderWidth: 2, style={[
a.absolute,
a.flex_row,
{
bottom: a.p_xs.padding,
right: a.p_xs.padding,
gap: 3,
},
largeAltBadge && {
gap: 4,
},
]}>
{isCropped && (
<View
style={[
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Fullscreen
fill={t.atoms.text_contrast_high.color}
width={largeAltBadge ? 18 : 12}
/>
</View>
)}
{hasAlt && (
<View
style={[
a.justify_center,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Text
style={[
a.font_bold,
largeAltBadge ? a.text_xs : {fontSize: 8},
]}>
<Trans>ALT</Trans>
</Text>
</View>
)}
</View>
) : null}
<MediaInsetBorder
style={
focused && {
borderWidth: 2,
}
} }
} />
/> </Pressable>
</Pressable> </ImageContextMenu>
</Animated.View> </Animated.View>
) )
} }
+57 -38
View File
@@ -9,8 +9,9 @@ import {Trans} from '@lingui/react/macro'
import {type Dimensions} from '#/lib/media/types' import {type Dimensions} from '#/lib/media/types'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, tokens, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -52,46 +53,64 @@ export function GalleryItem({
const hasAlt = !!image.alt const hasAlt = !!image.alt
const hideBadges = const hideBadges =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const aspect =
image.aspectRatio && image.aspectRatio.height > 0
? image.aspectRatio.width / image.aspectRatio.height
: undefined
// The tap handler and the peek-commit handler do the same thing: open the
// lightbox with this cell's ref + dims so the lightbox's return animation
// can target the original thumbnail.
const openLightboxAtIndex = onPress
? () => onPress(index, containerRefs, thumbDimsRef.current.slice())
: undefined
return ( return (
<View style={a.flex_1} ref={containerRefs[index]} collapsable={false}> <View style={a.flex_1} ref={containerRefs[index]} collapsable={false}>
<Pressable <ImageContextMenu
onPress={ fullsizeUri={image.fullsize}
onPress thumbUri={image.thumb}
? () => onPress(index, containerRefs, thumbDimsRef.current.slice()) aspectRatio={aspect}
: undefined borderRadius={tokens.borderRadius.md}
} onPreviewPress={openLightboxAtIndex}
onPressIn={onPressIn ? () => onPressIn(index) : undefined} style={a.flex_1}>
onLongPress={onLongPress ? () => onLongPress(index) : undefined} <Pressable
android_ripple={{ onPress={openLightboxAtIndex}
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2), onPressIn={onPressIn ? () => onPressIn(index) : undefined}
foreground: true, onLongPress={onLongPress ? () => onLongPress(index) : undefined}
}} android_ripple={{
style={[ color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
a.flex_1, foreground: true,
a.overflow_hidden,
t.atoms.bg_contrast_25,
imageStyle,
]}
accessibilityRole="button"
accessibilityLabel={image.alt || _(msg`Image`)}
accessibilityHint="">
<Image
source={{uri: image.thumb}}
style={[a.flex_1]}
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
onLoad={e => {
thumbDimsRef.current[index] = {
width: e.source.width,
height: e.source.height,
}
}} }}
loading="lazy" style={[
/> a.flex_1,
<MediaInsetBorder style={insetBorderStyle} /> a.overflow_hidden,
</Pressable> t.atoms.bg_contrast_25,
imageStyle,
]}
accessibilityRole="button"
accessibilityLabel={image.alt || _(msg`Image`)}
accessibilityHint="">
<Image
source={{uri: image.thumb}}
style={[a.flex_1]}
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
onLoad={e => {
thumbDimsRef.current[index] = {
width: e.source.width,
height: e.source.height,
}
}}
loading="lazy"
useAppleWebpCodec
/>
<MediaInsetBorder style={insetBorderStyle} />
</Pressable>
</ImageContextMenu>
{hasAlt && !hideBadges ? ( {hasAlt && !hideBadges ? (
<View <View
accessible={false} accessible={false}
@@ -0,0 +1,436 @@
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,
staleTime: 0,
})
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
emoji
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>
<Trans>Open chat</Trans>
</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`Tap to request access to join this group chat`
: l`Tap to join this group chat immediately`
}
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 * as Dialog from '#/components/Dialog'
import {type DialogControlProps} from '#/components/Dialog' import {type DialogControlProps} from '#/components/Dialog'
import {GroupChatJoinDialog} from '#/components/intents/GroupChatJoinDialog'
import {VerifyEmailIntentDialog} from '#/components/intents/VerifyEmailIntentDialog' import {VerifyEmailIntentDialog} from '#/components/intents/VerifyEmailIntentDialog'
interface Context { interface Context {
verifyEmailDialogControl: DialogControlProps verifyEmailDialogControl: DialogControlProps
verifyEmailState: {code: string} | undefined verifyEmailState: {code: string} | undefined
setVerifyEmailState: (state: {code: string} | undefined) => void setVerifyEmailState: (state: {code: string} | undefined) => void
groupChatJoinDialogControl: DialogControlProps
groupChatJoinState: {code: string} | undefined
setGroupChatJoinState: (state: {code: string} | undefined) => void
} }
const Context = createContext({} as Context) const Context = createContext({} as Context)
@@ -19,20 +36,70 @@ export function Provider({children}: {children: React.ReactNode}) {
const [verifyEmailState, setVerifyEmailState] = useState< const [verifyEmailState, setVerifyEmailState] = useState<
{code: string} | undefined {code: string} | undefined
>() >()
const groupChatJoinDialogControl = Dialog.useDialogControl()
const [groupChatJoinState, setGroupChatJoinState] = useState<
{code: string} | undefined
>()
const {hasSession} = useSession()
const groupChatLanding = useActiveGroupChatJoinRequest()
const setActiveLanding = useSetActiveLanding()
const prefetchJoinLinkPreviews = usePrefetchJoinLinkPreviews()
const landingHandledRef = useRef(false)
useEffect(() => {
if (hasSession && groupChatLanding && !landingHandledRef.current) {
landingHandledRef.current = true
const code = groupChatLanding.code
setActiveLanding(undefined)
const prefetch = prefetchJoinLinkPreviews({
codes: [code],
hasSession: true,
})
void Promise.race([
prefetch,
new Promise(res => setTimeout(res, 200)),
]).finally(() => {
setGroupChatJoinState({code})
groupChatJoinDialogControl.open()
})
}
if (!groupChatLanding) {
landingHandledRef.current = false
}
}, [
hasSession,
groupChatLanding,
setActiveLanding,
setGroupChatJoinState,
prefetchJoinLinkPreviews,
groupChatJoinDialogControl,
])
const value = useMemo( const value = useMemo(
() => ({ () => ({
verifyEmailDialogControl, verifyEmailDialogControl,
verifyEmailState, verifyEmailState,
setVerifyEmailState, setVerifyEmailState,
groupChatJoinDialogControl,
groupChatJoinState,
setGroupChatJoinState,
}), }),
[verifyEmailDialogControl, verifyEmailState, setVerifyEmailState], [
verifyEmailDialogControl,
verifyEmailState,
setVerifyEmailState,
groupChatJoinDialogControl,
groupChatJoinState,
setGroupChatJoinState,
],
) )
return ( return (
<Context.Provider value={value}> <Context.Provider value={value}>
{children} {children}
<VerifyEmailIntentDialog /> <VerifyEmailIntentDialog />
<GroupChatJoinDialog />
</Context.Provider> </Context.Provider>
) )
} }
@@ -75,9 +75,11 @@ export function ReportDialog(
() => (props.subject ? parseReportSubject(props.subject) : undefined), () => (props.subject ? parseReportSubject(props.subject) : undefined),
[props.subject], [props.subject],
) )
const propsOnClose = props.onClose
const onClose = useCallback(() => { const onClose = useCallback(() => {
ax.metric('reportDialog:close', {}) ax.metric('reportDialog:close', {})
}, [ax]) propsOnClose?.()
}, [ax, propsOnClose])
return ( return (
<Dialog.Outer control={props.control} onClose={onClose}> <Dialog.Outer control={props.control} onClose={onClose}>
<Dialog.Handle /> <Dialog.Handle />
@@ -88,4 +88,8 @@ export type ReportDialogProps = {
* Called if the report was successfully submitted. * Called if the report was successfully submitted.
*/ */
onAfterSubmit?: () => void onAfterSubmit?: () => void
/**
* Called after the dialog finishes closing.
*/
onClose?: () => void
} }
@@ -87,6 +87,7 @@ function Inner({
alt={_( alt={_(
msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`, msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`,
)} )}
useAppleWebpCodec
/> />
</View> </View>
@@ -69,6 +69,7 @@ export function LiveEventFeedCardCompact({
style={[a.absolute, a.inset_0, a.w_full, a.h_full]} style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
contentFit="cover" contentFit="cover"
placeholderContentFit="cover" placeholderContentFit="cover"
useAppleWebpCodec
/> />
<LinearGradient <LinearGradient
@@ -77,6 +77,7 @@ export function LiveEventFeedCardWide({
style={[a.absolute, a.inset_0, a.w_full, a.h_full]} style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
contentFit="cover" contentFit="cover"
placeholderContentFit="cover" placeholderContentFit="cover"
useAppleWebpCodec
/> />
<LinearGradient <LinearGradient
@@ -54,6 +54,7 @@ export function LinkPreview({
contentFit="cover" contentFit="cover"
onLoad={() => setImageLoadError(false)} onLoad={() => setImageLoadError(false)}
onError={() => setImageLoadError(true)} onError={() => setImageLoadError(true)}
useAppleWebpCodec
/> />
)} )}
{linkMeta && (!linkMeta.image || imageLoadError) && ( {linkMeta && (!linkMeta.image || imageLoadError) && (
@@ -147,6 +147,7 @@ export function LiveStatus({
contentFit="cover" contentFit="cover"
style={[a.absolute, a.inset_0]} style={[a.absolute, a.inset_0]}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
/> />
<LiveIndicator <LiveIndicator
size="large" size="large"
+16 -2
View File
@@ -178,7 +178,8 @@ export async function post(
writes: writes, writes: writes,
validate: true, validate: true,
}) })
} catch (e: any) { } catch (err) {
const e = err as Error
logger.error(`Failed to create post`, { logger.error(`Failed to create post`, {
safeMessage: e.message, safeMessage: e.message,
}) })
@@ -431,6 +432,16 @@ async function resolveMedia(
}, },
} }
} }
if (resolvedLink.type === 'chat-invite' && resolvedLink.view) {
return {
$type: 'app.bsky.embed.external',
external: {
uri: resolvedLink.uri,
title: resolvedLink.view.name,
description: `${resolvedLink.view.memberCount}/${resolvedLink.view.memberLimit}`,
},
}
}
} }
return undefined return undefined
} }
@@ -474,6 +485,7 @@ async function computeCid(record: AppBskyFeedPost.Record): Promise<string> {
} }
// Returns a transformed version of the object for use in DAG-CBOR. // Returns a transformed version of the object for use in DAG-CBOR.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function prepareForHashing(v: any): any { function prepareForHashing(v: any): any {
// IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing, // IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing,
// the API client will convert this for you but we're hashing in the client, // the API client will convert this for you but we're hashing in the client,
@@ -496,9 +508,10 @@ function prepareForHashing(v: any): any {
// Walk through plain objects // Walk through plain objects
if (isPlainObject(v)) { if (isPlainObject(v)) {
const obj: any = {} const obj: Record<string, unknown> = {}
let pure = true let pure = true
for (const key in v) { for (const key in v) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
let value = v[key] let value = v[key]
// `value` is undefined // `value` is undefined
if (value === undefined) { if (value === undefined) {
@@ -517,6 +530,7 @@ function prepareForHashing(v: any): any {
return v return v
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isPlainObject(v: any): boolean { function isPlainObject(v: any): boolean {
if (typeof v !== 'object' || v === null) { if (typeof v !== 'object' || v === null) {
return false return false
+28 -1
View File
@@ -2,11 +2,12 @@ import {
type AppBskyFeedDefs, type AppBskyFeedDefs,
type AppBskyGraphDefs, type AppBskyGraphDefs,
type BskyAgent, type BskyAgent,
type ChatBskyGroupDefs,
type ComAtprotoRepoStrongRef, type ComAtprotoRepoStrongRef,
} from '@atproto/api' } from '@atproto/api'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants' import {DM_SERVICE_HEADERS, IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
import {resolveShortLink} from '#/lib/link-meta/resolve-short-link' import {resolveShortLink} from '#/lib/link-meta/resolve-short-link'
import {downloadAndResize} from '#/lib/media/manip' import {downloadAndResize} from '#/lib/media/manip'
@@ -16,6 +17,7 @@ import {
} from '#/lib/strings/starter-pack' } from '#/lib/strings/starter-pack'
import { import {
convertBskyAppUrlIfNeeded, convertBskyAppUrlIfNeeded,
getChatInviteCodeFromUrl,
isBskyCustomFeedUrl, isBskyCustomFeedUrl,
isBskyListUrl, isBskyListUrl,
isBskyPostUrl, isBskyPostUrl,
@@ -71,12 +73,20 @@ type ResolvedStarterPackRecord = {
view: AppBskyGraphDefs.StarterPackView view: AppBskyGraphDefs.StarterPackView
} }
type ResolvedChatInvite = {
type: 'chat-invite'
uri: string
code: string
view?: ChatBskyGroupDefs.JoinLinkPreviewView
}
export type ResolvedLink = export type ResolvedLink =
| ResolvedExternalLink | ResolvedExternalLink
| ResolvedPostRecord | ResolvedPostRecord
| ResolvedFeedRecord | ResolvedFeedRecord
| ResolvedListRecord | ResolvedListRecord
| ResolvedStarterPackRecord | ResolvedStarterPackRecord
| ResolvedChatInvite
export class EmbeddingDisabledError extends Error { export class EmbeddingDisabledError extends Error {
constructor() { constructor() {
@@ -141,6 +151,19 @@ export async function resolveLink(
view: res.data.list, view: res.data.list,
} }
} }
const chatInviteCode = getChatInviteCodeFromUrl(uri)
if (chatInviteCode) {
const res = await agent.chat.bsky.group.getJoinLinkPreviews(
{codes: [chatInviteCode]},
{headers: DM_SERVICE_HEADERS},
)
return {
type: 'chat-invite',
uri,
code: chatInviteCode,
view: res.data.joinLinkPreviews[0],
}
}
if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) { if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) {
const parsed = parseStarterPackUri(uri) const parsed = parseStarterPackUri(uri)
if (!parsed) { if (!parsed) {
@@ -246,6 +269,10 @@ async function resolveExternal(
title: result.title ?? '', title: result.title ?? '',
description: result.description ?? '', description: result.description ?? '',
thumb: result.image ? await imageToThumb(result.image) : undefined, thumb: result.image ? await imageToThumb(result.image) : undefined,
/*
* New fields from Standard Site integration. Other fields are derived from
* opengraph/oembed as before.
*/
associatedRefs: result.associatedRefs, associatedRefs: result.associatedRefs,
view: result.view, view: result.view,
} }
+1
View File
@@ -13,6 +13,7 @@ export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app'
export const DEFAULT_SERVICE = BSKY_SERVICE export const DEFAULT_SERVICE = BSKY_SERVICE
const HELP_DESK_LANG = 'en-us' const HELP_DESK_LANG = 'en-us'
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}` export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
export const CHAT_SERVICE = 'https://api.bsky.chat'
export const EMBED_SERVICE = 'https://embed.bsky.app' export const EMBED_SERVICE = 'https://embed.bsky.app'
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js` export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download' export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
+51
View File
@@ -5,7 +5,11 @@ import * as WebBrowser from 'expo-web-browser'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {parseLinkingUrl} from '#/lib/parseLinkingUrl' import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers'
import {usePrefetchJoinLinkPreviews} from '#/state/queries/join-links'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useSetActiveLanding} from '#/state/shell/landing'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util' import {useCloseAllActiveElements} from '#/state/util'
import {useIntentDialogs} from '#/components/intents/IntentDialogs' import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
@@ -25,6 +29,7 @@ export function useIntentHandler() {
const ax = useAnalytics() const ax = useAnalytics()
const composeIntent = useComposeIntent() const composeIntent = useComposeIntent()
const verifyEmailIntent = useVerifyEmailIntent() const verifyEmailIntent = useVerifyEmailIntent()
const groupChatJoinIntent = useGroupChatJoinIntent()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {tryApplyUpdate} = useApplyPullRequestOTAUpdate() const {tryApplyUpdate} = useApplyPullRequestOTAUpdate()
@@ -44,6 +49,11 @@ export function useIntentHandler() {
}) })
} }
const urlp = parseLinkingUrl(url) const urlp = parseLinkingUrl(url)
const chatInviteMatch = urlp.pathname.match(CHAT_INVITE_CODE_REGEX)
if (chatInviteMatch) {
groupChatJoinIntent(chatInviteMatch[1], url)
return
}
const [, intent, intentType] = urlp.pathname.split('/') const [, intent, intentType] = urlp.pathname.split('/')
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the // On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
@@ -99,6 +109,7 @@ export function useIntentHandler() {
ax, ax,
composeIntent, composeIntent,
verifyEmailIntent, verifyEmailIntent,
groupChatJoinIntent,
currentAccount, currentAccount,
tryApplyUpdate, tryApplyUpdate,
]) ])
@@ -162,6 +173,46 @@ export function useComposeIntent() {
) )
} }
export function useGroupChatJoinIntent() {
const closeAllActiveElements = useCloseAllActiveElements()
const {hasSession} = useSession()
const {groupChatJoinDialogControl: control, setGroupChatJoinState: setState} =
useIntentDialogs()
const {requestSwitchToAccount} = useLoggedOutViewControls()
const setActiveLanding = useSetActiveLanding()
const prefetchJoinLinkPreviews = usePrefetchJoinLinkPreviews()
return useCallback(
(code: string, uri?: string) => {
closeAllActiveElements()
if (hasSession) {
setState({code})
const prefetch = prefetchJoinLinkPreviews({
codes: [code],
hasSession: true,
})
void Promise.race([
prefetch,
new Promise(res => setTimeout(res, 200)),
]).finally(() => {
control.open()
})
} else {
setActiveLanding({type: 'groupchat', uri: uri ?? '', code})
requestSwitchToAccount({requestedAccount: 'groupchat'})
}
},
[
closeAllActiveElements,
hasSession,
control,
setState,
prefetchJoinLinkPreviews,
requestSwitchToAccount,
setActiveLanding,
],
)
}
function useVerifyEmailIntent() { function useVerifyEmailIntent() {
const closeAllActiveElements = useCloseAllActiveElements() const closeAllActiveElements = useCloseAllActiveElements()
const {verifyEmailDialogControl: control, setVerifyEmailState: setState} = const {verifyEmailDialogControl: control, setVerifyEmailState: setState} =
+6 -6
View File
@@ -58,12 +58,12 @@ export function dateDiff(
if (diffSeconds < NOW) { if (diffSeconds < NOW) {
diff = { diff = {
value: 0, value: 0,
unit: 'now' as DateDiff['unit'], unit: 'now',
} }
} else if (diffSeconds < MINUTE) { } else if (diffSeconds < MINUTE) {
diff = { diff = {
value: diffSeconds, value: diffSeconds,
unit: 'second' as DateDiff['unit'], unit: 'second',
} }
} else if (diffSeconds < HOUR) { } else if (diffSeconds < HOUR) {
const value = const value =
@@ -72,7 +72,7 @@ export function dateDiff(
: Math.floor(diffSeconds / MINUTE) : Math.floor(diffSeconds / MINUTE)
diff = { diff = {
value, value,
unit: 'minute' as DateDiff['unit'], unit: 'minute',
} }
} else if (diffSeconds < DAY) { } else if (diffSeconds < DAY) {
const value = const value =
@@ -81,7 +81,7 @@ export function dateDiff(
: Math.floor(diffSeconds / HOUR) : Math.floor(diffSeconds / HOUR)
diff = { diff = {
value, value,
unit: 'hour' as DateDiff['unit'], unit: 'hour',
} }
} else if (diffSeconds < MONTH_30) { } else if (diffSeconds < MONTH_30) {
const value = const value =
@@ -90,7 +90,7 @@ export function dateDiff(
: Math.floor(diffSeconds / DAY) : Math.floor(diffSeconds / DAY)
diff = { diff = {
value, value,
unit: 'day' as DateDiff['unit'], unit: 'day',
} }
} else { } else {
const value = const value =
@@ -99,7 +99,7 @@ export function dateDiff(
: Math.floor(diffSeconds / MONTH_30) : Math.floor(diffSeconds / MONTH_30)
diff = { diff = {
value, value,
unit: 'month' as DateDiff['unit'], unit: 'month',
} }
} }
+2 -2
View File
@@ -19,8 +19,8 @@ export function makeProfileLink(
export function makeCustomFeedLink( export function makeCustomFeedLink(
did: string, did: string,
rkey: string, rkey: string,
segment?: string | undefined, segment?: string,
feedCacheKey?: 'discover' | 'explore' | undefined, feedCacheKey?: 'discover' | 'explore',
) { ) {
return ( return (
[`/profile`, did, 'feed', rkey, ...(segment ? [segment] : [])].join('/') + [`/profile`, did, 'feed', rkey, ...(segment ? [segment] : [])].join('/') +
+1
View File
@@ -64,6 +64,7 @@ export type CommonNavigatorParams = {
Topic: {topic: string} Topic: {topic: string}
MessagesConversation: {conversation: string; embed?: string; accept?: true} MessagesConversation: {conversation: string; embed?: string; accept?: true}
MessagesConversationSettings: {conversation: string} MessagesConversationSettings: {conversation: string}
MessagesJoinRequests: {conversation: string}
MessagesSettings: undefined MessagesSettings: undefined
MessagesInbox: undefined MessagesInbox: undefined
NotificationsActivityList: {posts: string} NotificationsActivityList: {posts: string}
+25 -2
View File
@@ -1,5 +1,5 @@
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import psl from 'psl' import {parse} from 'psl'
import TLDs from 'tlds' import TLDs from 'tlds'
import {BSKY_SERVICE} from '#/lib/constants' import {BSKY_SERVICE} from '#/lib/constants'
@@ -178,6 +178,29 @@ export function isBskyStarterPackUrl(url: string): boolean {
return false return false
} }
// Invite codes are 7 alphanumeric characters long, supporting up to 10 here to future-proof.
export const CHAT_INVITE_CODE_REGEX = /^\/c\/([a-zA-Z0-9]{7,10})$/
export function getChatInviteCodeFromUrl(url: string): string | undefined {
let pathname: string
if (isBskyAppUrl(url)) {
try {
pathname = new URL(url).pathname
} catch {
return undefined
}
} else if (url.startsWith('/')) {
pathname = url.split('?')[0].split('#')[0]
} else {
return undefined
}
return pathname.match(CHAT_INVITE_CODE_REGEX)?.[1]
}
export function isBskyChatInviteUrl(url: string): boolean {
return getChatInviteCodeFromUrl(url) !== undefined
}
export function isBskyDownloadUrl(url: string): boolean { export function isBskyDownloadUrl(url: string): boolean {
if (isExternalUrl(url)) { if (isExternalUrl(url)) {
return false return false
@@ -306,7 +329,7 @@ export function isPossiblyAUrl(str: string): boolean {
} }
export function splitApexDomain(hostname: string): [string, string] { export function splitApexDomain(hostname: string): [string, string] {
const hostnamep = psl.parse(hostname) const hostnamep = parse(hostname)
if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) { if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) {
return ['', hostname] return ['', hostname]
} }
File diff suppressed because one or more lines are too long
+1
View File
@@ -74,6 +74,7 @@ export const router = new Router<AllNavigatableRoutes>({
MessagesInbox: '/messages/inbox', MessagesInbox: '/messages/inbox',
MessagesConversation: '/messages/:conversation', MessagesConversation: '/messages/:conversation',
MessagesConversationSettings: '/messages/:conversation/settings', MessagesConversationSettings: '/messages/:conversation/settings',
MessagesJoinRequests: '/messages/:conversation/requests',
// starter packs // starter packs
Start: '/start/:name/:rkey', Start: '/start/:name/:rkey',
StarterPackEdit: '/starter-pack/edit/:rkey', StarterPackEdit: '/starter-pack/edit/:rkey',
@@ -1,26 +1,31 @@
import {View} from 'react-native' import {View} from 'react-native'
import {plural} from '@lingui/core/macro' import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {InlineLinkText} from '#/components/Link' import {type ConvoWithDetails} from '#/components/dms/util'
import {createStaticClick, InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function MembersAndRequests({ export function MembersAndRequests({
memberCount, convo,
memberLimit,
requestCount, requestCount,
hasMoreRequests, hasMoreRequests,
isOwner, isOwner,
}: { }: {
memberCount: number convo: Extract<ConvoWithDetails, {kind: 'group'}>
memberLimit: number
requestCount: number requestCount: number
hasMoreRequests: boolean hasMoreRequests: boolean
isOwner: boolean isOwner: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const memberCount = convo.details.memberCount
const memberLimit = convo.details.memberLimit
return ( return (
<View style={[a.flex_row, a.justify_between, a.px_xl, a.pt_xl, a.pb_sm]}> <View style={[a.flex_row, a.justify_between, a.px_xl, a.pt_xl, a.pb_sm]}>
@@ -42,8 +47,11 @@ export function MembersAndRequests({
<InlineLinkText <InlineLinkText
label={l`View incoming group chat requests`} label={l`View incoming group chat requests`}
style={[a.text_sm, a.text_right, a.font_semi_bold]} style={[a.text_sm, a.text_right, a.font_semi_bold]}
// TODO Need to implement this. -dsb {...createStaticClick(() => {
to="#"> navigation.navigate('MessagesJoinRequests', {
conversation: convo.view.id,
})
})}>
{hasMoreRequests {hasMoreRequests
? l({ ? l({
message: `${requestCount}+ requests`, message: `${requestCount}+ requests`,
@@ -247,8 +247,7 @@ function GroupSettings({
case 'MEMBERS_AND_REQUESTS': case 'MEMBERS_AND_REQUESTS':
return ( return (
<MembersAndRequests <MembersAndRequests
memberCount={convo.details.memberCount} convo={convo}
memberLimit={convo.details.memberLimit}
requestCount={requestCount} requestCount={requestCount}
hasMoreRequests={!!hasMoreRequests} hasMoreRequests={!!hasMoreRequests}
isOwner={isOwner} isOwner={isOwner}
+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>
)
}
+490
View File
@@ -0,0 +1,490 @@
import {useState} from 'react'
import {View} from 'react-native'
import {
ChatBskyGroupApproveJoinRequest,
type ChatBskyGroupListJoinRequests,
ChatBskyGroupRejectJoinRequest,
} from '@atproto/api'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {type InfiniteData, useQueryClient} from '@tanstack/react-query'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {isNetworkError} from '#/lib/hooks/useCleanError'
import {
type CommonNavigatorParams,
type NativeStackScreenProps,
type NavigationProp,
} from '#/lib/routes/types'
import {logger} from '#/logger'
import {ConvoProvider, useConvo} from '#/state/messages/convo'
import {ConvoStatus} from '#/state/messages/convo/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useJoinRequestMutation} from '#/state/queries/messages/join-requests'
import {
createListJoinRequestsQueryKey,
useListJoinRequestsQuery,
} from '#/state/queries/messages/list-join-requests'
import {useSession} from '#/state/session'
import {List} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf'
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {type ConvoWithDetails} from '#/components/dms/util'
import {Error} from '#/components/Error'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
import {KnownFollowers} from '#/components/KnownFollowers'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {InviteLinkDialog} from './components/InviteLinkDialog'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'MessagesJoinRequests'
>
export function MessagesJoinRequestsScreen(props: Props) {
const {t: l} = useLingui()
const aaCopy = useAgeAssuranceCopy()
return (
<AgeRestrictedScreen
screenTitle={l`Requests to join`}
infoText={aaCopy.chatsInfoText}>
<MessagesJoinRequestsScreenInner {...props} />
</AgeRestrictedScreen>
)
}
function MessagesJoinRequestsScreenInner({route}: Props) {
const convoId = route.params.conversation
return (
<Layout.Screen>
<ConvoProvider key={convoId} convoId={convoId}>
<JoinRequestsInner />
</ConvoProvider>
</Layout.Screen>
)
}
function JoinRequestsInner() {
const {t: l} = useLingui()
const convoState = useConvo()
const navigation = useNavigation<NavigationProp>()
if (convoState.status === ConvoStatus.Error) {
return (
<>
<Header />
<Error
title={l`Something went wrong`}
message={l`We couldn’t load this conversation’s join requests`}
onRetry={() => convoState.error.retry()}
sideBorders={false}
/>
</>
)
}
if (!convoState.convo) {
return (
<>
<Header />
<View style={[a.flex_1, a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
</>
)
}
if (convoState.convo.kind !== 'group') {
return (
<Error
title={l`Wrong kind of conversation`}
message={l`This screen is only available for group conversations.`}
onGoBack={() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.replace('Messages', {animation: 'pop'})
}
}}
/>
)
}
return <JoinRequestsList convo={convoState.convo} />
}
function JoinRequestsList({
convo,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
}) {
const t = useTheme()
const {t: l} = useLingui()
const moderationOpts = useModerationOpts()
const bottomBarOffset = useBottomBarOffset()
const {currentAccount} = useSession()
const navigation = useNavigation<NavigationProp>()
const queryClient = useQueryClient()
const inviteLinkControl = Dialog.useDialogControl()
const getRemainingRequestCount = () => {
const data = queryClient.getQueryData<
InfiniteData<ChatBskyGroupListJoinRequests.OutputSchema>
>(createListJoinRequestsQueryKey({convoId: convo.view.id}))
return data?.pages.reduce((sum, page) => sum + page.requests.length, 0) ?? 0
}
const [isPTRing, setIsPTRing] = useState(false)
const [footerHeight, setFooterHeight] = useState(0)
const owner = convo.primaryMember
const isOwner = !!owner && owner.did === currentAccount?.did
const {
data: joinRequestsData,
isPending,
isError,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
refetch,
} = useListJoinRequestsQuery({
convoId: convo.view.id,
})
const items =
joinRequestsData?.pages.flatMap(page =>
page.requests.map(request => request.requestedBy),
) ?? []
const requestCount =
joinRequestsData?.pages.reduce(
(sum, page) => sum + page.requests.length,
0,
) ?? 0
const {mutate: approveJoinRequest, isPending: isApprovePending} =
useJoinRequestMutation('approve', convo.view.id, {
onSuccess: () => {
Toast.show(l`Request approved.`)
if (getRemainingRequestCount() < 1) {
navigation.replace('MessagesConversationSettings', {
conversation: convo.view.id,
})
}
},
onError: error => {
let errorMessage = l`Failed to accept join request`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (
error instanceof ChatBskyGroupApproveJoinRequest.InvalidConvoError
) {
errorMessage = l`Conversation not found.`
} else if (
error instanceof ChatBskyGroupApproveJoinRequest.InsufficientRoleError
) {
errorMessage = l`Only admins can accept join requests.`
} else if (
error instanceof
ChatBskyGroupApproveJoinRequest.MemberLimitReachedError
) {
errorMessage = l`The member limit has been reached.`
}
Toast.show(errorMessage, {type: 'error'})
},
})
const {mutate: rejectJoinRequest, isPending: isRejectPending} =
useJoinRequestMutation('reject', convo.view.id, {
onSuccess: () => {
Toast.show(l`Request ignored.`)
if (getRemainingRequestCount() < 1) {
navigation.replace('MessagesConversationSettings', {
conversation: convo.view.id,
})
}
},
onError: error => {
let errorMessage = l`Failed to ignore join request`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (
error instanceof ChatBskyGroupRejectJoinRequest.InvalidConvoError
) {
errorMessage = l`Conversation not found.`
} else if (
error instanceof ChatBskyGroupRejectJoinRequest.InsufficientRoleError
) {
errorMessage = l`Only admins can ignore join requests.`
}
Toast.show(errorMessage, {type: 'error'})
},
})
const isMutating = isApprovePending || isRejectPending
const renderItem = ({item}: {item: bsky.profile.AnyProfileView}) => {
if (!moderationOpts) return null
return (
<View style={[a.relative, a.flex_1, a.p_lg]}>
<View style={[a.flex_row, a.align_start, a.gap_md]}>
<ProfileCard.Link profile={item}>
<ProfileCard.Avatar
profile={item}
moderationOpts={moderationOpts}
size={44}
disabledPreview
/>
</ProfileCard.Link>
<View>
<ProfileCard.Name profile={item} moderationOpts={moderationOpts} />
<ProfileCard.Handle profile={item} />
<View style={[a.mt_xs]}>
<KnownFollowers
profile={item}
moderationOpts={moderationOpts}
minimal
showIfEmpty
/>
</View>
<View style={[a.flex_row, a.align_center, a.gap_sm, a.mt_md]}>
<AcceptButton
disabled={isMutating}
onPress={() => approveJoinRequest({member: item.did})}
/>
<RejectButton
disabled={isMutating}
onPress={() => rejectJoinRequest({member: item.did})}
/>
</View>
</View>
</View>
</View>
)
}
const footer = (
<View
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}
style={[
a.absolute,
a.left_0,
a.right_0,
{bottom: 0},
a.px_xl,
a.border_t,
t.atoms.bg,
t.atoms.border_contrast_low,
{
paddingTop: a.py_lg.paddingTop,
paddingBottom: a.py_lg.paddingBottom + bottomBarOffset,
},
]}>
<Button
label={l`Edit invite link`}
size="large"
color="primary"
onPress={() => inviteLinkControl.open()}
style={[a.w_full]}>
<ButtonText>
<Trans context="button">Edit invite link</Trans>
</ButtonText>
</Button>
</View>
)
const onEndReached = async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more join requests', {message: err})
}
}
const onRefresh = async () => {
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh group chat requests', {message: err})
}
setIsPTRing(false)
}
if (isError) {
return (
<>
<Header count={requestCount} hasMoreRequests={hasNextPage} />
<View
style={[
a.flex_1,
a.align_center,
a.justify_center,
a.gap_sm,
a.p_lg,
]}>
<ErrorIcon size="3xl" fill={t.atoms.text_contrast_high.color} />
<Text
style={[
a.leading_snug,
a.text_center,
a.px_lg,
a.text_md,
t.atoms.text_contrast_high,
]}>
<Trans>Unable to fetch join requests.</Trans>
</Text>
<Button
color="primary"
label={l`Press to retry`}
onPress={() => void onRefresh()}
disabled={isPTRing}
size="large"
style={[a.mt_md]}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
<ButtonIcon icon={isPTRing ? Loader : RetryIcon} />
</Button>
</View>
</>
)
}
const showFooter = isOwner
return (
<>
<Header count={requestCount} hasMoreRequests={hasNextPage} />
<List
data={items}
keyExtractor={(item: bsky.profile.AnyProfileView) => item.did}
renderItem={renderItem}
ListEmptyComponent={
isPending ? (
<View
style={[a.flex_1, a.align_center, a.justify_center, a.py_4xl]}>
<Loader size="xl" />
</View>
) : null
}
contentContainerStyle={
showFooter ? {paddingBottom: footerHeight} : undefined
}
scrollIndicatorInsets={showFooter ? {bottom: footerHeight} : undefined}
refreshing={isPTRing}
onEndReached={() => void onEndReached()}
onRefresh={() => void onRefresh()}
keyboardDismissMode="on-drag"
sideBorders={false}
desktopFixedHeight
/>
{showFooter ? footer : null}
{owner && moderationOpts && (
<InviteLinkDialog
convo={convo}
control={inviteLinkControl}
owner={owner}
isOwner={isOwner}
moderationOpts={moderationOpts}
/>
)}
</>
)
}
function Header({
count,
hasMoreRequests,
}: {
count?: number
hasMoreRequests?: boolean
}) {
return (
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
{count === undefined ? (
<Trans>Requests to join</Trans>
) : hasMoreRequests ? (
<Plural
value={count}
other="#+ requests to join"
comment="Displayed when there are more requests to join a group chat than have been loaded"
/>
) : (
<Plural
value={count}
_0="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>
)
}
@@ -5,8 +5,7 @@ import {
moderateProfile, moderateProfile,
type ModerationOpts, type ModerationOpts,
} from '@atproto/api' } from '@atproto/api'
import {plural} from '@lingui/core/macro' import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
@@ -178,11 +177,7 @@ export function InviteLinkDialog({
<Text style={[a.text_md, a.leading_snug]}> <Text style={[a.text_md, a.leading_snug]}>
<Trans> <Trans>
Group chats can only have a maximum of{' '} Group chats can only have a maximum of{' '}
{plural(convo.details.memberLimit, { <Plural value={convo.details.memberLimit} other="# people" />.
one: '# person',
other: '# people',
})}
.
</Trans> </Trans>
</Text> </Text>
<Text style={[a.text_md, a.leading_snug]}> <Text style={[a.text_md, a.leading_snug]}>
@@ -297,8 +292,8 @@ export function InviteLinkDialog({
const linkEnabled = joinLink?.enabledStatus === 'enabled' const linkEnabled = joinLink?.enabledStatus === 'enabled'
const linkDisabled = joinLink?.enabledStatus === 'disabled' const linkDisabled = joinLink?.enabledStatus === 'disabled'
const joinLinkURI = joinLink?.code const joinLinkURI = joinLink?.code
? `https://bsky.app/chat/${joinLink.code}` ? `https://bsky.app/c/${joinLink.code}`
: 'https://bsky.app/chat' : 'https://bsky.app/'
const createdAt = joinLink ? new Date(joinLink.createdAt) : null const createdAt = joinLink ? new Date(joinLink.createdAt) : null
const currentOption = const currentOption =
whoCanJoinOptions.find( whoCanJoinOptions.find(
+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 {atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {DateDivider} from '#/components/dms/DateDivider' import {DateDivider} from '#/components/dms/DateDivider'
import {MessageItem} from '#/components/dms/MessageItem' import {MessageItem} from '#/components/dms/MessageItem'
import {MessageOverlays} from '#/components/dms/MessageOverlays'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup' import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
import {SystemMessageItem} from '#/components/dms/SystemMessageItem' import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
@@ -498,127 +499,133 @@ export function MessagesList({
return ( return (
<InviteLinkDialogProvider convo={convoState.convo}> <InviteLinkDialogProvider convo={convoState.convo}>
<KeyboardGestureArea <MessageOverlays>
interpolator="ios" <KeyboardGestureArea
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419 interpolator="ios"
offset={Math.round(inputHeightJS)} // HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
// slightly too buggy unfortunately, enable when possible offset={Math.round(inputHeightJS)}
// textInputNativeID={textInputId} // slightly too buggy unfortunately, enable when possible
style={[a.flex_1]}> // textInputNativeID={textInputId}
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} style={[a.flex_1]}>
<Animated.View style={[a.flex_1, animatedListStyle]}> {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<ScrollProvider onScroll={onScroll}> <Animated.View style={[a.flex_1, animatedListStyle]}>
<List <ScrollProvider onScroll={onScroll}>
ref={flatListRef} <List
data={renderItems} ref={flatListRef}
renderItem={renderItem} data={renderItems}
keyExtractor={keyExtractor} renderItem={renderItem}
disableFullWindowScroll={true} keyExtractor={keyExtractor}
disableVirtualization={true} disableFullWindowScroll={true}
// The extra two items account for the header and the footer components disableVirtualization={true}
initialNumToRender={IS_NATIVE ? 32 : 62} // The extra two items account for the header and the footer components
maxToRenderPerBatch={IS_WEB ? 32 : 62} initialNumToRender={IS_NATIVE ? 32 : 62}
keyboardDismissMode="interactive" maxToRenderPerBatch={IS_WEB ? 32 : 62}
keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive"
maintainVisibleContentPosition={{minIndexForVisible: 0}} keyboardShouldPersistTaps="handled"
removeClippedSubviews={false} maintainVisibleContentPosition={{minIndexForVisible: 0}}
sideBorders={false} removeClippedSubviews={false}
onContentSizeChange={onContentSizeChange} sideBorders={false}
onStartReached={onStartReached} onContentSizeChange={onContentSizeChange}
onScrollToIndexFailed={onScrollToIndexFailed} onStartReached={onStartReached}
showsVerticalScrollIndicator={!IS_ANDROID} onScrollToIndexFailed={onScrollToIndexFailed}
scrollEventThrottle={100} showsVerticalScrollIndicator={!IS_ANDROID}
ListHeaderComponent={ scrollEventThrottle={100}
<> ListHeaderComponent={
<MaybeLoader isLoading={convoState.isFetchingHistory} /> <>
{convoState.hasAllHistory ? ( <MaybeLoader isLoading={convoState.isFetchingHistory} />
convoState.convo?.kind === 'group' ? ( {convoState.hasAllHistory ? (
<MessagesListGroupInfoPanel convo={convoState.convo} /> convoState.convo?.kind === 'group' ? (
) : ( <MessagesListGroupInfoPanel convo={convoState.convo} />
<MessagesListInfoPanel convo={convoState.convo} /> ) : (
) <MessagesListInfoPanel convo={convoState.convo} />
) : null} )
</> ) : null}
} </>
// native only (prop is not supported on web) }
renderScrollComponent={renderScrollComponent} // native only (prop is not supported on web)
contentContainerStyle={{ renderScrollComponent={renderScrollComponent}
paddingBottom: platform({ contentContainerStyle={{
// ios is slightly larger as the input has no top padding paddingBottom: platform({
ios: tokens.space.lg, // ios is slightly larger as the input has no top padding
android: tokens.space.md, ios: tokens.space.lg,
web: 0, // web uses ListFooterComponent instead for scroll reasons android: tokens.space.md,
}), web: 0, // web uses ListFooterComponent instead for scroll reasons
}} }),
ListFooterComponent={ }}
<View ListFooterComponent={
style={web({height: tokens.space.md + inputHeightJS})} <View
onLayout={onFooterLayout} style={web({height: tokens.space.md + inputHeightJS})}
/> onLayout={onFooterLayout}
} />
style={[ }
web({ style={[
scrollbarWidth: 'thin', web({
scrollbarColor: `${t.palette.contrast_100} transparent`, scrollbarWidth: 'thin',
scrollbarGutter: 'stable', scrollbarColor: `${t.palette.contrast_100} transparent`,
}), scrollbarGutter: 'stable',
]} }),
pointerEvents={!hasScrolled ? 'none' : 'auto'} ]}
contentInset={{top: transparentHeaderHeight}} pointerEvents={!hasScrolled ? 'none' : 'auto'}
scrollIndicatorInsets={{top: transparentHeaderHeight}} contentInset={{top: transparentHeaderHeight}}
/> scrollIndicatorInsets={{top: transparentHeaderHeight}}
</ScrollProvider> />
</Animated.View> </ScrollProvider>
<KeyboardStickyView </Animated.View>
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]} <KeyboardStickyView
onLayout={onInputLayout} style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
minimumOffset={bottomInset} onLayout={onInputLayout}
offset={{ minimumOffset={bottomInset}
closed: platform({ offset={{
ios: tokens.space.lg, // hide bottom padding when closed closed: platform({
default: 0, ios: tokens.space.lg, // hide bottom padding when closed
}), default: 0,
opened: 0, }),
}}> opened: 0,
{footer ?? ( }}>
<ConversationFooter {footer ?? (
convoState={convoState} <ConversationFooter
hasAcceptOverride={hasAcceptOverride}> convoState={convoState}
{({loading}) => hasAcceptOverride={hasAcceptOverride}>
ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? ( {({loading}) =>
<MessageComposer ax.features.enabled(
textInputId={textInputId} ax.features.DmsNewMessageComposerEnable,
onSendMessage={(message: string) => ) ? (
void onSendMessage(message) <MessageComposer
} textInputId={textInputId}
hasEmbed={!!embedUri} onSendMessage={(message: string) =>
setEmbed={setEmbed} void onSendMessage(message)
loading={loading}> }
<MessageInputEmbed hasEmbed={!!embedUri}
embedUri={embedUri}
setEmbed={setEmbed} setEmbed={setEmbed}
/> loading={loading}>
</MessageComposer> <MessageInputEmbed
) : ( embedUri={embedUri}
<MessageInput setEmbed={setEmbed}
textInputId={textInputId} />
onSendMessage={onSendMessage} </MessageComposer>
hasEmbed={!!embedUri} ) : (
setEmbed={setEmbed} <MessageInput
loading={loading}> textInputId={textInputId}
<MessageInputEmbed onSendMessage={onSendMessage}
embedUri={embedUri} hasEmbed={!!embedUri}
setEmbed={setEmbed} setEmbed={setEmbed}
/> loading={loading}>
</MessageInput> <MessageInputEmbed
) embedUri={embedUri}
} setEmbed={setEmbed}
</ConversationFooter> />
)} </MessageInput>
</KeyboardStickyView> )
</KeyboardGestureArea> }
</ConversationFooter>
)}
</KeyboardStickyView>
</KeyboardGestureArea>
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />} {newMessagesPill.show && (
<NewMessagesPill onPress={scrollToEndOnPress} />
)}
</MessageOverlays>
</InviteLinkDialogProvider> </InviteLinkDialogProvider>
) )
} }
@@ -43,14 +43,11 @@ export function MessagesListGroupInfoPanel({
}, },
}) })
// TODO Enable this once the feature is working end-to-end. -dsb
// const joinLink = groupConvo?.details.joinLink
const isJoinLinkEnabled = false
// (isOwner && groupConvo) ||
// (!isOwner && groupConvo && joinLink?.enabledStatus === 'enabled')
const isOwner = convo.primaryMember?.did === currentAccount?.did const isOwner = convo.primaryMember?.did === currentAccount?.did
const isJoinLinkEnabled =
isOwner || convo.details.joinLink?.enabledStatus === 'enabled'
const members = (convo.members ?? []).filter( const members = (convo.members ?? []).filter(
profile => profile.did !== currentAccount?.did, profile => profile.did !== currentAccount?.did,
) )
@@ -28,6 +28,9 @@ export function RequestListItem({
const isDeletedAccount = const isDeletedAccount =
!convo.primaryMember || convo.primaryMember.handle === 'missing.invalid' !convo.primaryMember || convo.primaryMember.handle === 'missing.invalid'
const canAcceptRequest =
convo.kind === 'direct' || convo.details.lockStatus === 'unlocked'
return ( return (
<View style={[a.relative, a.flex_1]}> <View style={[a.relative, a.flex_1]}>
<ChatListItem convo={convo.view} showMenu={false}> <ChatListItem convo={convo.view} showMenu={false}>
@@ -65,7 +68,9 @@ export function RequestListItem({
]}> ]}>
{convo.primaryMember && !isDeletedAccount ? ( {convo.primaryMember && !isDeletedAccount ? (
<> <>
<AcceptChatButton convo={convo.view} currentScreen="list" /> {canAcceptRequest ? (
<AcceptChatButton convo={convo.view} currentScreen="list" />
) : null}
<RejectMenu <RejectMenu
convo={convo.view} convo={convo.view}
profile={convo.primaryMember} profile={convo.primaryMember}
@@ -83,6 +83,7 @@ function Page({
style={[a.w_full, a.aspect_square]} style={[a.w_full, a.aspect_square]}
alt={alt} alt={alt}
accessibilityIgnoresInvertColors={false} // I guess we do need it to blend into the background accessibilityIgnoresInvertColors={false} // I guess we do need it to blend into the background
useAppleWebpCodec
/> />
{page === 1 && ( {page === 1 && (
<Image <Image
@@ -97,6 +98,7 @@ function Page({
}, },
]} ]}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
alt={_(msg`Your profile picture`)} alt={_(msg`Your profile picture`)}
/> />
)} )}
@@ -28,11 +28,11 @@ import {preferencesQueryKey} from '#/state/queries/preferences'
import {RQKEY as profileRQKey} from '#/state/queries/profile' import {RQKEY as profileRQKey} from '#/state/queries/profile'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell' import {useOnboardingDispatch} from '#/state/shell'
import {useProgressGuideControls} from '#/state/shell/progress-guide'
import { import {
useActiveStarterPack, useActiveStarterPack,
useSetActiveStarterPack, useSetActiveStarterPack,
} from '#/state/shell/starter-pack' } from '#/state/shell/landing'
import {useProgressGuideControls} from '#/state/shell/progress-guide'
import { import {
OnboardingControls, OnboardingControls,
OnboardingHeaderSlot, OnboardingHeaderSlot,
@@ -1,16 +1,18 @@
import {useCallback, useEffect, useRef, useState} from 'react' import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {type ModerationOpts} from '@atproto/api' import {type ModerationOpts} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {useMutation, useQueryClient} from '@tanstack/react-query' import {useMutation, useQueryClient} from '@tanstack/react-query'
import * as bcp47Match from 'bcp-47-match'
import {wait} from '#/lib/async/wait' import {wait} from '#/lib/async/wait'
import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' import {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
import {logger} from '#/logger' import {logger} from '#/logger'
import {updateProfileShadow} from '#/state/cache/profile-shadow' import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {useLanguagePrefs} from '#/state/preferences'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import { import {
@@ -51,6 +53,19 @@ export function StepSuggestedAccounts() {
// so we can enable/disable the button without having to dig through the shadow cache // so we can enable/disable the button without having to dig through the shadow cache
const [followedUsers, setFollowedUsers] = useState<string[]>([]) const [followedUsers, setFollowedUsers] = useState<string[]>([])
/*
* Special language handling copied wholesale from the Explore screen
*/
const {contentLanguages} = useLanguagePrefs()
const useFullExperience = useMemo(() => {
if (contentLanguages.length === 0) return true
return bcp47Match.basicFilter('en', contentLanguages).length > 0
}, [contentLanguages])
const interestsDisplayNames = useInterestsDisplayNames()
const interests = Object.keys(interestsDisplayNames)
.sort(boostInterests(popularInterests))
.sort(boostInterests(state.interestsStepResults.selectedInterests))
const { const {
data: suggestedUsers, data: suggestedUsers,
isLoading, isLoading,
@@ -58,8 +73,8 @@ export function StepSuggestedAccounts() {
isRefetching, isRefetching,
refetch, refetch,
} = useSuggestedOnboardingUsers({ } = useSuggestedOnboardingUsers({
category: selectedInterest, category: selectedInterest || (useFullExperience ? null : interests[0]),
search: false, search: !useFullExperience,
overrideInterests: state.interestsStepResults.selectedInterests, overrideInterests: state.interestsStepResults.selectedInterests,
}) })
@@ -90,6 +105,7 @@ export function StepSuggestedAccounts() {
ax.metric('suggestedUser:follow', { ax.metric('suggestedUser:follow', {
logContext: 'Onboarding', logContext: 'Onboarding',
location: 'FollowAll', location: 'FollowAll',
recSource: !useFullExperience ? 'Search' : undefined,
recId: suggestedUsers?.recId, recId: suggestedUsers?.recId,
position: i, position: i,
suggestedDid: did, suggestedDid: did,
@@ -140,6 +156,7 @@ export function StepSuggestedAccounts() {
seenProfilesRef.current.add(did) seenProfilesRef.current.add(did)
ax.metric('suggestedUser:seen', { ax.metric('suggestedUser:seen', {
logContext: 'Onboarding', logContext: 'Onboarding',
recSource: !useFullExperience ? 'Search' : undefined,
recId: suggestedUsers?.recId, recId: suggestedUsers?.recId,
position, position,
suggestedDid: did, suggestedDid: did,
@@ -147,7 +164,7 @@ export function StepSuggestedAccounts() {
}) })
} }
}, },
[ax, selectedInterest, suggestedUsers?.recId], [ax, selectedInterest, suggestedUsers?.recId, useFullExperience],
) )
useEffect(() => { useEffect(() => {
@@ -233,6 +250,7 @@ export function StepSuggestedAccounts() {
position={index} position={index}
category={selectedInterest} category={selectedInterest}
onSeen={onProfileSeen} onSeen={onProfileSeen}
recSource={!useFullExperience ? 'Search' : undefined}
recId={suggestedUsers.recId} recId={suggestedUsers.recId}
/> />
))} ))}
@@ -105,6 +105,7 @@ function GermLogo({size}: {size: 'small' | 'large'}) {
source={require('../../../../assets/images/germ_logo.webp')} source={require('../../../../assets/images/germ_logo.webp')}
accessibilityIgnoresInvertColors={false} accessibilityIgnoresInvertColors={false}
contentFit="cover" contentFit="cover"
useAppleWebpCodec
style={[ style={[
a.rounded_full, a.rounded_full,
size === 'large' ? {width: 32, height: 32} : {width: 16, height: 16}, size === 'large' ? {width: 32, height: 32} : {width: 16, height: 16},
@@ -123,7 +123,11 @@ export function AutomationLabelSettingsScreen({}: Props) {
paddingRight: 20, // helps visually center paddingRight: 20, // helps visually center
}, },
]}> ]}>
<UserAvatar size={42} avatar={profile.avatar} type="user" /> <UserAvatar
size={42}
avatar={profile.avatar}
type={profile.associated?.labeler ? 'labeler' : 'user'}
/>
<View> <View>
<View style={[a.flex_row, a.align_baseline]}> <View style={[a.flex_row, a.align_baseline]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}> <View style={[a.flex_row, a.align_center, a.gap_xs]}>
+1 -1
View File
@@ -9,7 +9,7 @@ import {FEEDBACK_FORM_URL} from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useServiceQuery} from '#/state/queries/service' import {useServiceQuery} from '#/state/queries/service'
import {useStarterPackQuery} from '#/state/queries/starter-packs' import {useStarterPackQuery} from '#/state/queries/starter-packs'
import {useActiveStarterPack} from '#/state/shell/starter-pack' import {useActiveStarterPack} from '#/state/shell/landing'
import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
import { import {
initialState, initialState,
@@ -19,7 +19,7 @@ import {useStarterPackQuery} from '#/state/queries/starter-packs'
import { import {
useActiveStarterPack, useActiveStarterPack,
useSetActiveStarterPack, useSetActiveStarterPack,
} from '#/state/shell/starter-pack' } from '#/state/shell/landing'
import {LoggedOutScreenState} from '#/view/com/auth/LoggedOut' import {LoggedOutScreenState} from '#/view/com/auth/LoggedOut'
import {formatCount} from '#/view/com/util/numeric/format' import {formatCount} from '#/view/com/util/numeric/format'
import {Logo} from '#/view/icons/Logo' import {Logo} from '#/view/icons/Logo'

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