Merge branch 'main' into app-2067

This commit is contained in:
vineyardbovines
2026-05-04 10:51:22 -04:00
198 changed files with 98635 additions and 46890 deletions
+26 -3
View File
@@ -109,23 +109,46 @@ jobs:
run: | run: |
if [ -f "build.tar.gz" ]; then if [ -f "build.tar.gz" ]; then
echo "Extracting build.tar.gz..." echo "Extracting build.tar.gz..."
mkdir ios-build rm -rf ios-build
mkdir -p ios-build
tar -xzf build.tar.gz -C ios-build tar -xzf build.tar.gz -C ios-build
echo "Extraction completed successfully" echo "Extraction completed successfully"
echo ""
echo "Top-level extracted files:"
find ios-build -maxdepth 3 -print
echo ""
echo "Searching for IPA..."
IPA_PATH="$(find ios-build -type f -name '*.ipa' -print -quit)"
if [ -z "$IPA_PATH" ]; then
echo "ERROR: No .ipa found anywhere under ios-build."
echo "Archive contents:"
tar -tzf build.tar.gz | sed -n '1,200p'
exit 1
fi
BUILD_DIR="$(dirname "$IPA_PATH")"
echo "Found IPA at: $IPA_PATH"
echo "Build dir: $BUILD_DIR"
echo ""
echo "Build dir contents:"
ls -la "$BUILD_DIR"
echo "BUILD_DIR=$BUILD_DIR" >> $GITHUB_ENV
else else
echo "Archive file not found!" echo "Archive file not found!"
exit 1 exit 1
fi fi
- name: 🚀 Deploy - name: 🚀 Deploy
run: eas submit -p ios --non-interactive --path ios-build/ios/build/Bluesky.ipa run: eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
- name: 🪲 Upload dSYM to Sentry - name: 🪲 Upload dSYM to Sentry
run: > run: >
SENTRY_ORG=blueskyweb SENTRY_ORG=blueskyweb
SENTRY_PROJECT=app SENTRY_PROJECT=app
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
yarn sentry-cli debug-files upload ios-build/ios/build/Bluesky.app.dSYM.zip --include-sources yarn sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
- name: 📚 Get version from package.json - name: 📚 Get version from package.json
id: get-build-info id: get-build-info
+44 -2
View File
@@ -239,10 +239,52 @@ jobs:
yarn use-build-number-with-bump yarn use-build-number-with-bump
eas build -p ios eas build -p ios
--profile testflight --profile testflight
--local --output build.ipa --non-interactive --local --output build.tar.gz --non-interactive
- name: 📂 Extract build artifact
run: |
if [ -f "build.tar.gz" ]; then
echo "Extracting build.tar.gz..."
rm -rf ios-build
mkdir -p ios-build
tar -xzf build.tar.gz -C ios-build
echo "Extraction completed successfully"
echo ""
echo "Top-level extracted files:"
find ios-build -maxdepth 3 -print
echo ""
echo "Searching for IPA..."
IPA_PATH="$(find ios-build -type f -name '*.ipa' -print -quit)"
if [ -z "$IPA_PATH" ]; then
echo "ERROR: No .ipa found anywhere under ios-build."
echo "Archive contents:"
tar -tzf build.tar.gz | sed -n '1,200p'
exit 1
fi
BUILD_DIR="$(dirname "$IPA_PATH")"
echo "Found IPA at: $IPA_PATH"
echo "Build dir: $BUILD_DIR"
echo ""
echo "Build dir contents:"
ls -la "$BUILD_DIR"
echo "BUILD_DIR=$BUILD_DIR" >> $GITHUB_ENV
else
echo "Archive file not found!"
exit 1
fi
- name: 🚀 Deploy - name: 🚀 Deploy
run: eas submit -p ios --non-interactive --path build.ipa run: eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
- name: 🪲 Upload dSYM to Sentry
run: >
SENTRY_ORG=blueskyweb
SENTRY_PROJECT=app
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
yarn sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
- name: ⬇️ Restore Cache - name: ⬇️ Restore Cache
id: get-base-commit id: get-base-commit
+165 -81
View File
@@ -1,4 +1,4 @@
# CLAUDE.md - Bluesky Social App Development Guide # CLAUDE.md – Bluesky Social App Development Guide
This document provides guidance for working effectively in the Bluesky Social app codebase. This document provides guidance for working effectively in the Bluesky Social app codebase.
@@ -7,13 +7,17 @@ This document provides guidance for working effectively in the Bluesky Social ap
Bluesky Social is a cross-platform social media application built with React Native and Expo. It runs on iOS, Android, and Web, connecting to the AT Protocol (atproto) decentralized social network. Bluesky Social is a cross-platform social media application built with React Native and Expo. It runs on iOS, Android, and Web, connecting to the AT Protocol (atproto) decentralized social network.
**Tech Stack:** **Tech Stack:**
- React 19.1
- React Native 0.81 with Expo 54 - React Native 0.81 with Expo 54
- TypeScript - TypeScript 6
- React Navigation for routing - React Navigation 7 for routing
- TanStack Query (React Query) for data fetching - TanStack Query (React Query) for data fetching
- Lingui for internationalization - Lingui 5 for internationalization
- Custom design system called ALF (Application Layout Framework) - Custom design system called ALF (Application Layout Framework)
Prefer using the latest features available for each of these libraries (exact versions are found in `package.json`). For example, prefer `@lingui/react/macro` over `@lingui/react`. Suggest refactoring legacy or deprecated uses.
## Essential Commands ## Essential Commands
```bash ```bash
@@ -162,6 +166,10 @@ its own looks more like a single component file.
### Documentation and Tests Within Features ### Documentation and Tests Within Features
Comment code when necessary to explain the “why” behind something; avoid
comments that simply describe the code. Avoid Unicode characters in comments,
e.g., use `-` not `—`.
For larger features or components, it's helpful to include a README.md file For larger features or components, it's helpful to include a README.md file
within the directory that explains the purpose of the feature, how it works, and within the directory that explains the purpose of the feature, how it works, and
any important implementation details. The `/Component/index.tsx` pattern lends any important implementation details. The `/Component/index.tsx` pattern lends
@@ -182,6 +190,14 @@ ALF is the custom design system. It uses Tailwind-inspired naming with underscor
### Basic Usage ### Basic Usage
Generally, order atoms by:
- Flexbox configuration, e.g., `a.flex_row`
- Spacing, e.g., `a.px_md`
- Text styles, e.g., `a.font_bold`
- Themes, e.g., `t.atoms.text`,
- Raw styles, e.g., `{backgroundColor: t.palette.primary_500}`
```tsx ```tsx
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
@@ -190,9 +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]}> <Text style={[a.text_md, a.font_bold, t.atoms.text]}>Hello</Text>
Hello
</Text>
</View> </View>
) )
} }
@@ -200,20 +214,23 @@ function MyComponent() {
### Key Concepts ### Key Concepts
**Static Atoms** - Theme-independent styles imported from `atoms`: **Static Atoms** – Theme-independent styles imported from `atoms`:
```tsx ```tsx
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc. // a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
``` ```
**Theme Atoms** - Theme-dependent colors from `useTheme()`: **Theme Atoms** – Theme-dependent colors from `useTheme()`:
```tsx ```tsx
const t = useTheme() const t = useTheme()
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc. // t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
// t.palette.primary_500, t.palette.negative_400, etc. // t.palette.primary_500, t.palette.negative_400, etc.
``` ```
**Platform Utilities** - For platform-specific styles: **Platform Utilities** – For platform-specific styles:
```tsx ```tsx
import {web, native, ios, android, platform} from '#/alf' import {web, native, ios, android, platform} from '#/alf'
@@ -225,7 +242,8 @@ const styles = [
] ]
``` ```
**Breakpoints** - Responsive design: **Breakpoints** – Responsive design:
```tsx ```tsx
import {useBreakpoints} from '#/alf' import {useBreakpoints} from '#/alf'
@@ -245,6 +263,38 @@ if (gtMobile) {
## Component Patterns ## Component Patterns
- Prefer fragment shorthand over `Fragment` unless a `key` is needed.
- Prefer functions over arrow functions for component declarations.
- Prefer prop destructuring via parameters over a const within the component.
- Prefer inline types over `Props` types or interfaces.
- Set reasonable defaults for optional props.
```tsx
import {Fragment} from 'react'
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {Text} from '#/components/Typography'
function MyComponent({foo = []}: {foo?: string[]}) {
const {t: l} = useLingui()
return (
<>
<View><Text><Trans>Example</Trans><Text></View>
<View>
{foo.map((foo, index) => (
<Fragment key={foo}>
<Text>{index}</Text>
<Text>{foo}</Text>
</Fragment>
))}
</View>
</>
);
}
```
### Dialog Component ### Dialog Component
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state. Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
@@ -263,23 +313,29 @@ function MyFeature() {
<Dialog.Outer control={control}> <Dialog.Outer control={control}>
{/* Typically the inner part is in its own component */} {/* Typically the inner part is in its own component */}
<Dialog.Handle /> {/* Native-only drag handle */} <DialogInner />
<Dialog.ScrollableInner label={_(msg`My Dialog`)}>
<Dialog.Header>
<Dialog.HeaderText>Title</Dialog.HeaderText>
</Dialog.Header>
<Text>Dialog content here</Text>
<Button label="Done" onPress={() => control.close()}>
<ButtonText>Done</ButtonText>
</Button>
<Dialog.Close /> {/* Web-only X button in top left */}
</Dialog.ScrollableInner>
</Dialog.Outer> </Dialog.Outer>
</> </>
) )
} }
function DialogInner() {
return (
<>
<Dialog.Handle /> {/* Native-only drag handle */}
<Dialog.ScrollableInner label={l`My Dialog`}>
<Dialog.Header>
<Dialog.HeaderText>Title</Dialog.HeaderText>
</Dialog.Header>
<Text>Dialog content here</Text>
<Button label="Done" onPress={() => control.close()}>
<ButtonText>Done</ButtonText>
</Button>
<Dialog.Close /> {/* Web-only X button in top left */}
</Dialog.ScrollableInner>
</>
)
}
``` ```
### Menu Component ### Menu Component
@@ -345,6 +401,7 @@ import {Button, ButtonText, ButtonIcon} from '#/components/Button'
``` ```
**Button Props:** **Button Props:**
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'` - `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
- `size`: `'tiny'` | `'small'` | `'large'` - `size`: `'tiny'` | `'small'` | `'large'`
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'` - `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
@@ -384,39 +441,66 @@ import * as TextField from '#/components/forms/TextField'
## Internationalization (i18n) ## Internationalization (i18n)
All user-facing strings must be wrapped for translation using Lingui. All user-facing strings must be wrapped for translation using Lingui. Include `comment` and/or `context` props when necessary to avoid ambiguity, e.g., “Post” as a noun vs a verb.
Prefer using `t` via `import {useLingui} '@lingui/react/macro'` vs `_` via `import {useLingui} from '@lingui/react'`. Alias `t` to `l` to avoid collisions with `const t = useTheme()`. Refactor existing uses of ``_(msg`foo`)`` to use `` l`foo` ``.
Prefer Unicode punctuation over keyboard punctuation, e.g., `“quote”` over `"quote"`. Prefer en dashes preceded by a non-breaking space over em dashes, e.g., `one – two` over `one—two`.
```tsx ```tsx
import {msg, plural} from '@lingui/core/macro' import {plural} from '@lingui/core/macro'
import {Trans} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
function MyComponent() { function MyComponent() {
const {_} = useLingui() const {t: l} = useLingui()
// Simple strings - use msg() with _() function // Simple strings - use the l macro
const title = _(msg`Settings`) const title = l`Settings`
const errorMessage = _(msg`Something went wrong`) const errorMessage = l({
message: 'Something went wrong',
comment: 'Generic error message for unknown/unhandled errors.',
context: 'Toast',
})
// Strings with variables // Strings with variables
const greeting = _(msg`Hello, ${name}!`) const greeting = l`Hello, ${name}!`
// Pluralization // Pluralization
const countLabel = _(plural(count, { const countLabel = plural(count, {
one: '# item', one: '# item',
other: '# items', other: '# items',
})) })
// JSX content - use Trans component // JSX content - use Trans component
return ( return (
<Text> <Text>
<Trans>Welcome to <Text style={a.font_bold}>Bluesky</Text></Trans> <Trans>
Welcome to <Text style={a.font_bold}>Bluesky</Text>, {name}!
</Trans>
</Text> </Text>
) )
} }
``` ```
Prefer `i18n.date` for date and time formatting. This ensures formatting is re-applied when the language changes at runtime. Refactor existing uses of `Intl.DateTimeFormat` to use `i18n.date`.
```tsx
import {useLingui} from '@lingui/react/macro'
function MyComponent() {
const {i18n} = useLingui()
const createdAt = new Date()
return i18n.date(createdAt, {
dateStyle: 'medium',
timeStyle: 'medium',
})
}
```
**Commands:** **Commands:**
```bash ```bash
# DO NOT run these commands - extraction and compilation are handled by a nightly CI job # DO NOT run these commands - extraction and compilation are handled by a nightly CI job
yarn intl:extract # Extract new strings to locale files yarn intl:extract # Extract new strings to locale files
@@ -473,7 +557,7 @@ export function useProfileMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async (data) => { mutationFn: async data => {
// Update logic // Update logic
}, },
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
@@ -481,7 +565,7 @@ export function useProfileMutation() {
queryKey: createProfileQueryKey({did: variables.did}), queryKey: createProfileQueryKey({did: variables.did}),
}) })
}, },
onError: (error) => { onError: error => {
if (isNetworkError(error)) { if (isNetworkError(error)) {
// don't log, but inform user // don't log, but inform user
} else if (error instanceof AppBskyExampleProcedure.ExampleError) { } else if (error instanceof AppBskyExampleProcedure.ExampleError) {
@@ -490,7 +574,7 @@ export function useProfileMutation() {
// Log unexpected errors to Sentry // Log unexpected errors to Sentry
logger.error('Error updating profile', {safeMessage: error}) logger.error('Error updating profile', {safeMessage: error})
} }
} },
}) })
} }
@@ -505,21 +589,25 @@ export function useProfileCacheMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return (data: Partial<Profile>) => { return (data: Partial<Profile>) => {
queryClient.setQueryData(createProfileQueryKey({did: data.did}), oldData => { queryClient.setQueryData(
if (!oldData) return oldData createProfileQueryKey({did: data.did}),
return {...oldData, ...data} oldData => {
}) if (!oldData) return oldData
return {...oldData, ...data}
},
)
} }
} }
``` ```
**Stale Time Constants** (from `src/state/queries/index.ts`): **Stale Time Constants** (from `src/state/queries/index.ts`):
```tsx ```tsx
STALE.SECONDS.FIFTEEN // 15 seconds STALE.SECONDS.FIFTEEN // 15 seconds
STALE.MINUTES.ONE // 1 minute STALE.MINUTES.ONE // 1 minute
STALE.MINUTES.FIVE // 5 minutes STALE.MINUTES.FIVE // 5 minutes
STALE.HOURS.ONE // 1 hour STALE.HOURS.ONE // 1 hour
STALE.INFINITY // Never stale STALE.INFINITY // Never stale
``` ```
**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these: **Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
@@ -565,12 +653,7 @@ function SettingsScreen() {
const autoplayDisabled = useAutoplayDisabled() const autoplayDisabled = useAutoplayDisabled()
const setAutoplayDisabled = useSetAutoplayDisabled() const setAutoplayDisabled = useSetAutoplayDisabled()
return ( return <Toggle value={autoplayDisabled} onValueChange={setAutoplayDisabled} />
<Toggle
value={autoplayDisabled}
onValueChange={setAutoplayDisabled}
/>
)
} }
``` ```
@@ -604,13 +687,9 @@ import {type CommonNavigatorParams} from '#/lib/routes/types'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
export function ProfileScreen({route, navigation}: Props) { export function ProfileScreen({route, navigation}: Props) {
const {name} = route.params // Type-safe params const {name} = route.params // Type-safe params
return ( return <Layout.Screen>{/* Screen content */}</Layout.Screen>
<Layout.Screen>
{/* Screen content */}
</Layout.Screen>
)
} }
// Programmatic navigation // Programmatic navigation
@@ -637,8 +716,9 @@ Component.android.tsx # Android-only
``` ```
Example from Dialog: Example from Dialog:
- `src/components/Dialog/index.tsx` - Native (uses BottomSheet)
- `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives) - `src/components/Dialog/index.tsx` – Native (uses BottomSheet)
- `src/components/Dialog/index.web.tsx` – Web (uses modal with Radix primitives)
**Important:** The bundler automatically resolves platform-specific files. Just import normally: **Important:** The bundler automatically resolves platform-specific files. Just import normally:
@@ -653,6 +733,7 @@ const storage = IS_NATIVE
``` ```
Platform detection (for runtime logic, not imports): Platform detection (for runtime logic, not imports):
```tsx ```tsx
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env' import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
@@ -687,13 +768,13 @@ Common pitfalls to avoid in this codebase:
// WRONG - causes bugs with state updates, navigation, opening other dialogs // WRONG - causes bugs with state updates, navigation, opening other dialogs
const onConfirm = () => { const onConfirm = () => {
control.close() control.close()
navigation.navigate('Home') // May race with dialog animation navigation.navigate('Home') // May race with dialog animation
} }
// WRONG - same problem // WRONG - same problem
const onConfirm = () => { const onConfirm = () => {
control.close() control.close()
otherDialogControl.open() // Will likely fail or cause visual glitches otherDialogControl.open() // Will likely fail or cause visual glitches
} }
// CORRECT - action runs after dialog fully closes // CORRECT - action runs after dialog fully closes
@@ -720,12 +801,13 @@ const onConfirm = () => {
``` ```
This applies to: This applies to:
- Navigation (`navigation.navigate()`, `navigation.push()`) - Navigation (`navigation.navigate()`, `navigation.push()`)
- Opening other dialogs or menus - Opening other dialogs or menus
- State updates that affect UI (`setState`, `queryClient.invalidateQueries`) - State updates that affect UI (`setState`, `queryClient.invalidateQueries`)
- Callbacks passed from parent components - Callbacks passed from parent components
The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`. The Menu component on iOS specifically uses this pattern – see `src/components/Menu/index.tsx:151`.
### Controlled vs Uncontrolled Inputs ### Controlled vs Uncontrolled Inputs
@@ -748,10 +830,11 @@ Prefer `defaultValue` over `value` for TextInput on the old architecture:
### Platform-Specific Behavior ### Platform-Specific Behavior
Some components behave differently across platforms: Some components behave differently across platforms:
- `Dialog.Handle` - Only renders on native (drag handle for bottom sheet)
- `Dialog.Close` - Only renders on web (X button) - `Dialog.Handle` – Only renders on native (drag handle for bottom sheet)
- `Menu.Divider` - Only renders on web - `Dialog.Close` – Only renders on web (X button)
- `Menu.ContainerItem` - Only works on native - `Menu.Divider` – Only renders on web
- `Menu.ContainerItem` – Only works on native
Always test on multiple platforms when using these components. Always test on multiple platforms when using these components.
@@ -772,6 +855,7 @@ const handlePress = () => {
``` ```
Only use `useMemo`/`useCallback` when you have a specific reason, such as: Only use `useMemo`/`useCallback` when you have a specific reason, such as:
- The value is immediately used in an effect's dependency array - The value is immediately used in an effect's dependency array
- You're passing a callback to a non-React library that needs referential stability - You're passing a callback to a non-React library that needs referential stability
@@ -779,7 +863,7 @@ Only use `useMemo`/`useCallback` when you have a specific reason, such as:
1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful 1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful
2. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>` 2. **Translations**: Wrap ALL user-facing strings with ` `l` `` or `<Trans>`
3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles 3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles
@@ -793,14 +877,14 @@ Only use `useMemo`/`useCallback` when you have a specific reason, such as:
## Key Files Reference ## Key Files Reference
| Purpose | Location | | Purpose | Location |
|---------|----------| | ----------------- | -------------------------------------------- |
| Theme definitions | `src/alf/themes.ts` | | Theme definitions | `src/alf/themes.ts` |
| Design tokens | `src/alf/tokens.ts` | | Design tokens | `src/alf/tokens.ts` |
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) | | Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
| Navigation config | `src/Navigation.tsx` | | Navigation config | `src/Navigation.tsx` |
| Route definitions | `src/routes.ts` | | Route definitions | `src/routes.ts` |
| Route types | `src/lib/routes/types.ts` | | Route types | `src/lib/routes/types.ts` |
| Query hooks | `src/state/queries/*.ts` | | Query hooks | `src/state/queries/*.ts` |
| Session state | `src/state/session/index.tsx` | | Session state | `src/state/session/index.tsx` |
| i18n setup | `src/locale/i18n.ts` | | i18n setup | `src/locale/i18n.ts` |
+2
View File
@@ -66,6 +66,7 @@ module.exports = function (_config) {
infoPlist: { infoPlist: {
CADisableMinimumFrameDurationOnPhone: true, CADisableMinimumFrameDurationOnPhone: true,
UIBackgroundModes: ['remote-notification'], UIBackgroundModes: ['remote-notification'],
NSUserActivityTypes: ['INSendMessageIntent'],
NSCameraUsageDescription: NSCameraUsageDescription:
'Used for profile pictures, posts, and other kinds of content.', 'Used for profile pictures, posts, and other kinds of content.',
NSMicrophoneUsageDescription: NSMicrophoneUsageDescription:
@@ -123,6 +124,7 @@ module.exports = function (_config) {
'com.apple.developer.kernel.increased-memory-limit': true, 'com.apple.developer.kernel.increased-memory-limit': true,
'com.apple.developer.kernel.extended-virtual-addressing': true, 'com.apple.developer.kernel.extended-virtual-addressing': true,
'com.apple.security.application-groups': 'group.app.bsky', 'com.apple.security.application-groups': 'group.app.bsky',
'com.apple.developer.usernotifications.communication': true,
// 'com.apple.developer.device-information.user-assigned-device-name': true, // 'com.apple.developer.device-information.user-assigned-device-name': true,
}, },
privacyManifests: { privacyManifests: {
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M17 3a4 4 0 0 1 4 4v10a4 4 0 0 1-4 4h-2a1 1 0 1 1 0-2h2a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2a1 1 0 1 1 0-2h2Zm-6.707 4.793a1 1 0 0 1 1.414 0l3.5 3.5a1 1 0 0 1 0 1.414l-3.5 3.5a1 1 0 1 1-1.414-1.414L12.086 13H4a1 1 0 1 1 0-2h8.086l-1.793-1.793a1 1 0 0 1 0-1.414Z"/></svg>

After

Width:  |  Height:  |  Size: 360 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M14.3 23v-1.1a1 1 0 0 1 2 0V23a1 1 0 1 1-2 0Zm5.243-3.457a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM4.788 9.298a1 1 0 0 1 1.424 1.404l-.742.752-.004.005a5.003 5.003 0 1 0 7.075 7.075l.005-.004.752-.742a1 1 0 0 1 1.404 1.424l-.747.736a7.003 7.003 0 1 1-9.904-9.904l.737-.746ZM23 14.3a1 1 0 0 1 0 2h-1.1a1 1 0 1 1 0-2H23ZM10.044 4.05a7.005 7.005 0 0 1 9.905 9.906h0l-.737.746a1 1 0 0 1-1.424-1.404l.742-.752.004-.005a5.003 5.003 0 1 0-7.075-7.075l-.005.004-.752.742a1 1 0 0 1-1.404-1.424l.746-.737ZM2.1 7.7a1 1 0 1 1 0 2H1a1 1 0 0 1 0-2h1.1Zm-.157-5.757a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM7.7 2.1V1a1 1 0 1 1 2 0v1.1a1 1 0 0 1-2 0Z"/></svg>

After

Width:  |  Height:  |  Size: 807 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M3 16.8V7.2c0-.544-.001-1.011.03-1.395.033-.395.104-.789.297-1.167a3 3 0 0 1 1.31-1.31c.379-.193.772-.265 1.168-.297C6.188 2.999 6.657 3 7.2 3H11a1 1 0 1 1 0 2H7.2c-.576 0-.949 0-1.232.023-.272.022-.373.06-.422.085a1 1 0 0 0-.437.437c-.025.05-.062.15-.085.422C5.001 6.251 5 6.623 5 7.2v9.6c0 .577.001.95.024 1.232.023.272.06.373.085.422a1 1 0 0 0 .437.437c.05.025.15.063.422.085.283.023.656.024 1.232.024h9.6c.576 0 .949-.001 1.232-.024.272-.022.373-.06.422-.085a1 1 0 0 0 .437-.437c.025-.049.062-.15.085-.422.023-.283.024-.655.024-1.232V13a1 1 0 1 1 2 0v3.8c0 .543.001 1.011-.03 1.395-.033.395-.104.788-.297 1.167a3 3 0 0 1-1.31 1.311c-.379.193-.772.264-1.168.296-.383.031-.852.031-1.395.031H7.2c-.543 0-1.012 0-1.395-.031-.396-.032-.789-.103-1.167-.296a3 3 0 0 1-1.31-1.311c-.194-.379-.265-.772-.298-1.167C3 17.81 3 17.343 3 16.8M16.629 2.957a3 3 0 0 1 4.242 0l.172.171a3 3 0 0 1 0 4.243L13 15.414a2 2 0 0 1-1.414.586H9a1 1 0 0 1-1-1v-2.586A2 2 0 0 1 8.586 11zM10 14h1.586l8.043-8.043a1 1 0 0 0 0-1.414l-.172-.172a1 1 0 0 0-1.414 0L10 12.414z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z"/></svg>

After

Width:  |  Height:  |  Size: 502 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1zm5 2a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1"/></svg>

After

Width:  |  Height:  |  Size: 376 B

+1 -1
View File
@@ -2,7 +2,7 @@
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover">
<meta name="referrer" content="origin-when-cross-origin"> <meta name="referrer" content="origin-when-cross-origin">
<!-- <!--
Preconnect to essential domains Preconnect to essential domains
+747
View File
@@ -0,0 +1,747 @@
{
"src/alf/util/flatten.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/analytics/PassiveAnalytics.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/analytics/metadata.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/analytics/metrics/client.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/analytics/metrics/client.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 5
}
},
"src/analytics/metrics/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/Button.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/Composer/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/Dialog/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/Dialog/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 4
}
},
"src/components/DraggableList/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/FeedCard.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/FocusScope/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/InternationalPhoneCodeSelect.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/Layout/const.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/Lists.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/Menu/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/Menu/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/Portal.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/Post/Embed/ImageEmbed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/PostControls/BookmarkButton.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/ProfileHoverCard/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/Select/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 4
}
},
"src/components/Select/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/Select/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/components/StarterPack/ProfileStarterPacks.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/StarterPack/Wizard/WizardEditListDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialogs/DeviceLocationRequestDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialogs/EmailDialog/components/ResendEmailText.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialogs/GifSelect.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialogs/LanguageSelectDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialogs/MutedWords.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialogs/PostInteractionSettingsDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/dialogs/lists/CreateOrEditListDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/forms/DateField/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/forms/SegmentedControl.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/forms/ToggleButton.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/hooks/useFollowMethods.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/images/AutoSizedImage.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/images/Gallery/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 5
}
},
"src/components/images/Gallery/useKeyboardHandlers.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 6
}
},
"src/components/images/Gallery/usePointerHandlers.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 6
}
},
"src/components/images/ImageLayoutGrid.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/images/ImageLayoutGridItem.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/moderation/ReportDialog/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/features/liveNow/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/geolocation/service.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/ScrollContext.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/lib/api/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 5
}
},
"src/lib/async/retry.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/async/until.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/broadcast/stub.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/functions.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 6
}
},
"src/lib/getUserDisplayName.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/hooks/useAccountSwitcher.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/hooks/useCleanError.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/hooks/useNonReactiveCallback.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/hooks/useOTAUpdates.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/hooks/useRequireEmailVerification.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/hooks/useToggleMutationQueue.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/hooks/useWebScrollRestoration.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/international-telephone-codes.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/media/manip.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/media/save-image.ios.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/media/save-image.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/merge-refs.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/react-query.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/routes/router.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/routes/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/strings/errors.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/locale/i18n.web.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/platform/polyfills.web.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Bookmarks/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Deactivated.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Login/ChooseAccountForm.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Login/ForgotPasswordForm.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Login/LoginForm.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Login/SetNewPasswordForm.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Moderation/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/screens/ModerationInteractionSettings/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Onboarding/StepFinished/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Onboarding/StepInterests/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/screens/PostThread/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/screens/Profile/Header/EditProfileDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/screens/Profile/Header/ProfileHeaderLabeler.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/screens/Profile/Header/Shell.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Profile/Sections/Feed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Profile/components/GermButton.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Settings/FindContactsSettings.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Settings/components/ChangePasswordDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/screens/Settings/components/DeactivateAccountDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Settings/components/DeleteAccountDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/screens/Signup/StepInfo/Policies.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/SignupQueued.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/cache/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/feed-feedback.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/state/messages/convo/agent.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/state/messages/events/agent.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/state/persisted/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/persisted/index.web.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/persisted/util.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/queries/notifications/feed.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/state/queries/nuxs/definitions.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/queries/pinned-post.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/queries/post-feed.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/state/queries/postgate/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/state/queries/search-posts.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/state/queries/threadgate/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/queries/util.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/session/agent.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/shell/progress-guide.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/storage/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 8
}
},
"src/view/com/composer/Composer.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/composer/drafts/state/queries.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/composer/photos/OpenCameraBtn.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/feeds/ComposerPrompt.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/feeds/ProfileFeedgens.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/view/com/lightbox/ImageViewing/@types/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/lightbox/ImageViewing/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/lists/ListMembers.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/view/com/lists/MyLists.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/lists/ProfileLists.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/view/com/notifications/NotificationFeed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/notifications/NotificationFeedItem.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/view/com/pager/Pager.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 4
}
},
"src/view/com/pager/PagerWithHeader.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/view/com/pager/PagerWithHeader.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/pager/TabBar.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/posts/FeedShutdownMsg.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/posts/PostFeed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/posts/PostFeedErrorMessage.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/profile/ProfileMenu.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 6
}
},
"src/view/com/testing/TestCtrls.e2e.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/util/EmptyState.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/util/ErrorBoundary.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/util/EventStopper.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/util/Link.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/util/List.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/util/List.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 14
}
},
"src/view/com/util/MainScrollProvider.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/util/ViewSelector.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 6
}
},
"src/view/com/util/Views.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/util/fab/FABInner.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/screens/Debug.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/screens/DebugMod.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/shell/createNativeStackNavigatorWithAuth.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
}
}
+1 -1
View File
@@ -229,7 +229,7 @@ export default defineConfig(
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too * v8 `warn` ones are probably worth fixing. `off` ones are a bit too
* nit-picky * nit-picky
*/ */
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-empty-object-type': 'off', '@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off', '@typescript-eslint/no-unsafe-function-type': 'off',
+7 -3
View File
@@ -61,9 +61,13 @@ jest.mock('expo-media-library', () => ({
usePermissions: jest.fn(() => [true]), usePermissions: jest.fn(() => [true]),
})) }))
jest.mock('lande', () => ({ jest.mock('@bsky.app/expo-guess-language', () => ({
__esModule: true, // this property makes it work guessLanguageSync: jest
default: jest.fn().mockReturnValue([['eng']]), .fn()
.mockReturnValue([{language: 'en', confidence: 1}]),
guessLanguageAsync: jest
.fn()
.mockResolvedValue([{language: 'en', confidence: 1}]),
})) }))
jest.mock('sentry-expo', () => ({ jest.mock('sentry-expo', () => ({
+7
View File
@@ -8,6 +8,13 @@
<string>com.apple.usernotifications.service</string> <string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key> <key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string> <string>$(PRODUCT_MODULE_NAME).NotificationService</string>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
</dict>
</dict> </dict>
<key>MainAppScheme</key> <key>MainAppScheme</key>
<string>bluesky</string> <string>bluesky</string>
+84 -7
View File
@@ -1,5 +1,6 @@
import UserNotifications import UserNotifications
import UIKit import UIKit
import Intents
let APP_GROUP = "group.app.bsky" let APP_GROUP = "group.app.bsky"
typealias ContentHandler = (UNNotificationContent) -> Void typealias ContentHandler = (UNNotificationContent) -> Void
@@ -40,17 +41,18 @@ class NotificationService: UNNotificationServiceExtension {
} }
self.bestAttempt = bestAttempt self.bestAttempt = bestAttempt
if reason == "chat-message" {
if reason == "chat-message" || reason == "chat-reaction" {
mutateWithChatMessage(bestAttempt) mutateWithChatMessage(bestAttempt)
let finalContent = createCommunicationNotification(
from: bestAttempt,
userInfo: request.content.userInfo
)
contentHandler(finalContent)
} else { } else {
mutateWithBadge(bestAttempt) mutateWithBadge(bestAttempt)
contentHandler(bestAttempt)
} }
// Any image downloading (or other network tasks) should be handled at the end
// of this block. Otherwise, if there is a timeout and serviceExtensionTimeWillExpire
// gets called, we might not have all the needed mutations completed in time.
contentHandler(bestAttempt)
} }
override func serviceExtensionTimeWillExpire() { override func serviceExtensionTimeWillExpire() {
@@ -61,6 +63,81 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(bestAttempt) contentHandler(bestAttempt)
} }
// MARK: Communication Notification
func createCommunicationNotification(
from content: UNMutableNotificationContent,
userInfo: [AnyHashable: Any]
) -> UNNotificationContent {
let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown"
let convoId = userInfo["convoId"] as? String
var avatarImage: INImage? = nil
if let avatarUrlString = userInfo["senderAvatarUrl"] as? String {
avatarImage = downloadAvatarImage(from: avatarUrlString)
}
let senderHandleValue = userInfo["senderHandle"] as? String
let senderHandle = INPersonHandle(value: senderHandleValue, type: .unknown)
let sender = INPerson(
personHandle: senderHandle,
nameComponents: nil,
displayName: senderDisplayName,
image: avatarImage,
contactIdentifier: nil,
customIdentifier: nil
)
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content.body,
speakableGroupName: nil,
conversationIdentifier: convoId,
serviceName: nil,
sender: sender,
attachments: nil
)
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
interaction.donate(completion: nil)
do {
return try content.updating(from: intent)
} catch {
return content
}
}
func downloadAvatarImage(from urlString: String) -> INImage? {
let thumbnailUrlString = urlString.replacingOccurrences(
of: "/img/avatar/",
with: "/img/avatar_thumbnail/"
)
guard let url = URL(string: thumbnailUrlString) else { return nil }
var request = URLRequest(url: url)
request.timeoutInterval = 5
var imageData: Data? = nil
let semaphore = DispatchSemaphore(value: 0)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data,
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 {
imageData = data
}
semaphore.signal()
}
task.resume()
semaphore.wait()
guard let data = imageData else { return nil }
return INImage(imageData: data)
}
// MARK: Mutations // MARK: Mutations
func mutateWithBadge(_ content: UNMutableNotificationContent) { func mutateWithBadge(_ content: UNMutableNotificationContent) {
@@ -13,7 +13,7 @@ class BackgroundNotificationHandler(
return return
} }
if (remoteMessage.data["reason"] == "chat-message") { if (remoteMessage.data["reason"] == "chat-message" || remoteMessage.data["reason"] == "chat-reaction") {
mutateWithChatMessage(remoteMessage) mutateWithChatMessage(remoteMessage)
} else { } else {
mutateWithOtherReason(remoteMessage) mutateWithOtherReason(remoteMessage)
+6 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.121.0", "version": "1.122.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@@ -51,7 +51,7 @@
"lint": "eslint --cache --quiet src", "lint": "eslint --cache --quiet src",
"lint-native": "swiftlint ./modules && ktlint ./modules", "lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules", "lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsc --project ./tsconfig.check.json", "typecheck": "tsgo --project ./tsconfig.check.json",
"e2e:mock-server": "cd dev-env && yarn start", "e2e:mock-server": "cd dev-env && yarn start",
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios", "e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android", "e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
@@ -81,10 +81,12 @@
"icons:optimize": "svgo -f ./assets/icons" "icons:optimize": "svgo -f ./assets/icons"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.19.9", "@atproto/api": "^0.19.11",
"@atproto/syntax": "0.5.2",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.7", "@bsky.app/alf": "^0.1.7",
"@bsky.app/expo-guess-language": "^0.2.8",
"@bsky.app/expo-image-crop-tool": "^0.5.0", "@bsky.app/expo-image-crop-tool": "^0.5.0",
"@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",
@@ -261,6 +263,7 @@
"@types/psl": "^1.1.1", "@types/psl": "^1.1.1",
"@types/react": "^19.1.12", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@typescript/native-preview": "^7.0.0-dev.20260428.1",
"babel-jest": "^29.7.0", "babel-jest": "^29.7.0",
"babel-plugin-module-resolver": "^5.0.2", "babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "^19.1.0-rc.3", "babel-plugin-react-compiler": "^19.1.0-rc.3",
+2 -3
View File
@@ -1,4 +1,4 @@
import {type TextStyle} from 'react-native' import {type FontVariant, type TextStyle} from 'react-native'
import {IS_ANDROID, IS_WEB} from '#/env' import {IS_ANDROID, IS_WEB} from '#/env'
import {type Device, device} from '#/storage' import {type Device, device} from '#/storage'
@@ -81,10 +81,9 @@ export function applyFonts(style: TextStyle, fontFamily: 'system' | 'theme') {
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant} * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant}
*/ */
if (IS_WEB) { if (IS_WEB) {
// @ts-expect-error - web supports 'unicode' as a valid value for fontVariant
style.fontVariant = (style.fontVariant || []).concat( style.fontVariant = (style.fontVariant || []).concat(
'no-contextual', 'no-contextual',
'unicode', 'unicode' as FontVariant, // web supports 'unicode' as a valid value for fontVariant
) )
} else { } else {
style.fontVariant = (style.fontVariant || []).concat('no-contextual') style.fontVariant = (style.fontVariant || []).concat('no-contextual')
+3
View File
@@ -12,8 +12,11 @@ export enum Features {
ImageUploadsHighResolution = 'image_uploads:high_resolution', ImageUploadsHighResolution = 'image_uploads:high_resolution',
ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled', ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled',
GroupChatsEnable = 'group_chats:enable', GroupChatsEnable = 'group_chats:enable',
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable', DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
KlipyGifProviderEnable = 'klipy_gif_provider:enable', KlipyGifProviderEnable = 'klipy_gif_provider:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable', PostGalleryEmbedEnable = 'post_gallery_embed:enable',
AATest = 'aa-test', AATest = 'aa-test',
} }
+102 -1
View File
@@ -487,6 +487,7 @@ export type Events = {
| 'SeeMoreSuggestedUsers' | 'SeeMoreSuggestedUsers'
| 'ProgressGuide' | 'ProgressGuide'
location: 'Card' | 'Profile' | 'FollowAll' location: 'Card' | 'Profile' | 'FollowAll'
recSource?: 'Search'
recId?: number | string recId?: number | string
position: number position: number
suggestedDid: string suggestedDid: string
@@ -514,6 +515,7 @@ export type Events = {
| 'Onboarding' | 'Onboarding'
| 'SeeMoreSuggestedUsers' | 'SeeMoreSuggestedUsers'
| 'ProgressGuide' | 'ProgressGuide'
recSource?: 'Search'
recId?: number | string recId?: number | string
position: number position: number
suggestedDid: string suggestedDid: string
@@ -554,7 +556,11 @@ export type Events = {
| 'FindContacts' | 'FindContacts'
} }
'chat:create': { 'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' logContext:
| 'ProfileHeader'
| 'NewChatDialog'
| 'SendViaChatDialog'
| 'ConvoSettings'
} }
'chat:open': { 'chat:open': {
logContext: logContext:
@@ -562,6 +568,7 @@ export type Events = {
| 'NewChatDialog' | 'NewChatDialog'
| 'ChatsList' | 'ChatsList'
| 'SendViaChatDialog' | 'SendViaChatDialog'
| 'ConvoSettings'
} }
'groupchat:create': { 'groupchat:create': {
logContext: 'NewChatDialog' logContext: 'NewChatDialog'
@@ -798,6 +805,100 @@ export type Events = {
*/ */
resultSourceLanguage: string resultSourceLanguage: string
} }
'composer:language:suggestLanguage': {
os: Platform['OS']
/**
* The language we detected and suggested to the user as an override for the
* expected target language.
*/
suggestedLanguage: string | undefined
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
/**
* The length of the text being translated. We assume shorter texts are
* more likely to have inaccurate translations.
*/
textLength: number
}
'composer:language:acceptSuggestion': {
os: Platform['OS']
/**
* The language we detected and suggested to the user as an override for the
* expected target language.
*/
suggestedLanguage: string | undefined
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
/**
* The length of the text being translated. We assume shorter texts are
* more likely to have inaccurate translations.
*/
textLength: number
}
'composer:language:declineSuggestion': {
os: Platform['OS']
/**
* The language we detected and suggested to the user as an override for the
* expected target language.
*/
suggestedLanguage: string | undefined
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
/**
* The length of the text being translated. We assume shorter texts are
* more likely to have inaccurate translations.
*/
textLength: number
}
'composer:language:replyNudgeAccept': {
/**
* The language of the post the user is replying to.
*/
replyToLanguage: string
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
}
'composer:language:replyNudgeDecline': {
/**
* The language of the post the user is replying to.
*/
replyToLanguage: string
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
}
'composer:language:nudgeUser': {
os: Platform['OS']
/**
* The language we detected and suggested to the user as an override for the
* expected target language.
*/
suggestedLanguage: string | undefined
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
/**
* The length of the text being translated. We assume shorter texts are
* more likely to have inaccurate translations.
*/
textLength: number
}
'composer:language:langSelectorPressed': {
/**
* If the user was nudged by our language detection to update their language
*/
wasNudged: boolean
}
'postMenu:openMuteWordsDialog': { 'postMenu:openMuteWordsDialog': {
uri: string uri: string
@@ -1,4 +1,4 @@
import {useCallback} from 'react' import {useCallback, useMemo} from 'react'
import {moderateProfile, type ModerationOpts} from '@atproto/api' import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {keepPreviousData, useQuery} from '@tanstack/react-query' import {keepPreviousData, useQuery} from '@tanstack/react-query'
@@ -90,24 +90,34 @@ export function useAutocomplete({
} }
} }
if (showSearchFallback && q) {
results.unshift({
key: `search-${q}`,
type: 'search' as const,
value: q,
})
}
return results return results
}, },
[q, showSearchFallback, moderationOpts], [q, moderationOpts],
), ),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}) })
const items = useMemo(() => {
if (!query.data) {
return []
}
const results = [...query.data]
if (showSearchFallback && q) {
results.unshift({
key: `search-${q}`,
type: 'search' as const,
value: q,
})
}
return results
}, [query.data, showSearchFallback, q])
return { return {
query: q, query: q,
items: query.data || [], items,
} }
} }
+91 -173
View File
@@ -1,8 +1,7 @@
import {useCallback, useEffect} from 'react' import {useEffect} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native' import {View} from 'react-native'
import Animated, { import Animated, {
Easing, Easing,
interpolate,
type SharedValue, type SharedValue,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
@@ -16,36 +15,32 @@ import {atoms as a, useTheme} from '#/alf'
import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person' import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
type Layout = {
size: number
x: number
y: number
zIndex?: number
border?: boolean
}
type Props = { type Props = {
animate?: boolean animate?: boolean
profiles: bsky.profile.AnyProfileView[] profiles: bsky.profile.AnyProfileView[]
size?: 'small' | 'medium' | 'large' | number size?: number
} }
export function AvatarBubbles({ export function AvatarBubbles({
animate = false, animate = false,
profiles: allProfiles, profiles: allProfiles,
size = 'large', size = 120,
}: Props) { }: Props) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const profiles = allProfiles.filter(p => p.did !== currentAccount?.did) const profiles =
const containerSize = allProfiles.length > 2
typeof size === 'number' ? allProfiles.filter(p => p.did !== currentAccount?.did)
? size : allProfiles
: size === 'small' const scale = size / 120
? 40 const marginOffset = size < 120 ? -2 : 0
: size === 'medium'
? 56
: 120
const scale =
typeof size === 'number'
? size / 120
: size === 'small'
? 40 / 120
: size === 'medium'
? 56 / 120
: 1
const marginOffset = size === 'small' || size === 'medium' ? -2 : 0
const initialValue = animate ? 0 : 1 const initialValue = animate ? 0 : 1
const p0 = useSharedValue(initialValue) const p0 = useSharedValue(initialValue)
@@ -53,130 +48,50 @@ export function AvatarBubbles({
const p2 = useSharedValue(initialValue) const p2 = useSharedValue(initialValue)
const p3 = useSharedValue(initialValue) const p3 = useSharedValue(initialValue)
const animateScale = (p: Animated.SharedValue<number>, index: number) => {
p.set(0)
p.set(() =>
withDelay(
500 + index * 100,
withTiming(1, {
duration: 250,
easing: Easing.out(Easing.back(1.75)),
}),
),
)
}
const playScaleAnimation = useCallback(() => {
animateScale(p0, 0)
animateScale(p1, 1)
animateScale(p2, 2)
animateScale(p3, 3)
}, [p0, p1, p2, p3])
useEffect(() => { useEffect(() => {
if (!animate) return if (!animate) return
playScaleAnimation() const animateBubble = (p: SharedValue<number>, i: number) => {
}, [animate, playScaleAnimation]) p.set(0)
p.set(() =>
withDelay(
500 + i * 100,
withTiming(1, {
duration: 250,
easing: Easing.out(Easing.back(1.75)),
}),
),
)
}
animateBubble(p0, 0)
animateBubble(p1, 1)
animateBubble(p2, 2)
animateBubble(p3, 3)
}, [animate, p0, p1, p2, p3])
let avatars = ( const scales = [p0, p1, p2, p3]
<> const layouts = getLayouts(profiles.length)
<AvatarBubble
profile={profiles[0] ?? allProfiles[0]}
scale={p0}
size={76}
x={-2}
y={-2}
style={[a.z_20]}
includeProfileBorder
/>
<AvatarBubble
profile={profiles[1]}
scale={p1}
size={76}
x={42}
y={42}
style={[a.z_10]}
includeProfileBorder
/>
</>
)
if (profiles.length === 3) {
avatars = (
<>
<AvatarBubble
profile={profiles[0]}
scale={p0}
size={68}
x={-2}
y={-2}
/>
<AvatarBubble
profile={profiles[1]}
scale={p1}
size={56}
x={38}
y={62}
/>
<AvatarBubble
profile={profiles[2]}
scale={p2}
size={46}
x={71}
y={18}
/>
</>
)
}
if (profiles.length >= 4) {
avatars = (
<>
<AvatarBubble
profile={profiles[0]}
scale={p0}
size={68}
x={-2}
y={-2}
/>
<AvatarBubble
profile={profiles[1]}
scale={p1}
size={56}
x={60}
y={49}
/>
<AvatarBubble
profile={profiles[2]}
scale={p2}
size={42}
x={14}
y={74}
/>
<AvatarBubble profile={profiles[3]} scale={p3} size={32} x={72} y={9} />
</>
)
}
return ( return (
<Animated.View <Animated.View style={[a.p_2xs, {height: size, width: size}]}>
style={[
a.p_2xs,
{
height: containerSize,
width: containerSize,
},
]}>
<View <View
style={[ style={{
{ marginTop: marginOffset,
marginTop: marginOffset, marginLeft: marginOffset,
marginLeft: marginOffset, transform: [{scale}],
transform: [{scale}], transformOrigin: 'top left',
transformOrigin: 'top left', }}>
}, {layouts.map((layout, i) => (
]}> <AvatarBubble
{avatars} key={i}
profile={profiles[i]}
scale={scales[i]}
size={layout.size}
x={layout.x}
y={layout.y}
zIndex={layout.zIndex}
includeProfileBorder={layout.border}
/>
))}
</View> </View>
</Animated.View> </Animated.View>
) )
@@ -186,27 +101,23 @@ function AvatarBubble({
profile, profile,
scale, scale,
size, size,
style,
x, x,
y, y,
zIndex,
includeProfileBorder, includeProfileBorder,
}: { }: {
profile?: bsky.profile.AnyProfileView profile?: bsky.profile.AnyProfileView
scale: SharedValue<number> scale: SharedValue<number>
size: number size: number
style?: StyleProp<ViewStyle>
x: number x: number
y: number y: number
zIndex?: number
includeProfileBorder?: boolean includeProfileBorder?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
transform: [ transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}],
{translateX: x},
{translateY: y},
{scale: interpolate(scale.get(), [0, 1], [0, 1])},
],
})) }))
return ( return (
@@ -219,11 +130,17 @@ function AvatarBubble({
borderColor: t.atoms.text_inverted.color, borderColor: t.atoms.text_inverted.color,
borderWidth: 2, borderWidth: 2,
}, },
style, zIndex != null && {zIndex},
animatedStyle, animatedStyle,
]}> ]}>
{profile ? ( {profile ? (
<Avatar profile={profile} size={size} /> <UserAvatar
avatar={profile.avatar}
size={size}
type="user"
hideLiveBadge
noBorder
/>
) : ( ) : (
<AvatarPlaceholder size={size} /> <AvatarPlaceholder size={size} />
)} )}
@@ -231,25 +148,7 @@ function AvatarBubble({
) )
} }
function Avatar({ function AvatarPlaceholder({size}: {size: number}) {
profile,
size = 76,
}: {
profile: bsky.profile.AnyProfileView
size?: number
}) {
return (
<UserAvatar
avatar={profile.avatar}
size={size}
type="user"
hideLiveBadge
noBorder
/>
)
}
function AvatarPlaceholder({size = 76}: {size?: number}) {
const t = useTheme() const t = useTheme()
return ( return (
@@ -259,10 +158,7 @@ function AvatarPlaceholder({size = 76}: {size?: number}) {
a.justify_center, a.justify_center,
a.rounded_full, a.rounded_full,
t.atoms.bg_contrast_200, t.atoms.bg_contrast_200,
{ {width: size, height: size},
width: size,
height: size,
},
]}> ]}>
<PersonIcon <PersonIcon
width={size * 0.5} width={size * 0.5}
@@ -272,3 +168,25 @@ function AvatarPlaceholder({size = 76}: {size?: number}) {
</View> </View>
) )
} }
function getLayouts(count: number): Layout[] {
if (count === 3) {
return [
{size: 68, x: -2, y: -2},
{size: 56, x: 38, y: 62},
{size: 46, x: 71, y: 18},
]
}
if (count >= 4) {
return [
{size: 68, x: -2, y: -2},
{size: 56, x: 60, y: 49},
{size: 42, x: 14, y: 74},
{size: 32, x: 72, y: 9},
]
}
return [
{size: 76, x: -2, y: -2, zIndex: 20, border: true},
{size: 76, x: 42, y: 42, zIndex: 10, border: true},
]
}
+6
View File
@@ -77,6 +77,10 @@ export type ButtonState = {
focused: boolean focused: boolean
pressed: boolean pressed: boolean
disabled: boolean disabled: boolean
/**
* Alias for hovered || focused || pressed
*/
interacting: boolean
} }
export type ButtonContext = VariantProps & ButtonState export type ButtonContext = VariantProps & ButtonState
@@ -120,6 +124,7 @@ const Context = createContext<VariantProps & ButtonState>({
focused: false, focused: false,
pressed: false, pressed: false,
disabled: false, disabled: false,
interacting: false,
}) })
Context.displayName = 'ButtonContext' Context.displayName = 'ButtonContext'
@@ -536,6 +541,7 @@ export const Button = forwardRef<View, ButtonProps>(
const context = useMemo<ButtonContext>( const context = useMemo<ButtonContext>(
() => ({ () => ({
...state, ...state,
interacting: state.hovered || state.focused || state.pressed,
variant, variant,
color, color,
size, size,
+18 -7
View File
@@ -54,7 +54,7 @@ import {HITSLOP_10} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {logger} from '#/logger' import {logger} from '#/logger'
import {atoms as a, platform, tokens, useTheme} from '#/alf' import {atoms as a, flatten, platform, tokens, useTheme} from '#/alf'
import { import {
Context, Context,
ItemContext, ItemContext,
@@ -527,7 +527,8 @@ export function AuxiliaryView({
} }
}) })
const menuContext = useMemo(() => ({align}), [align]) const xOffset = (flatten(style)?.marginLeft as number) ?? 0
const menuContext = useMemo(() => ({align, xOffset}), [align, xOffset])
const onLayout = useCallback(() => { const onLayout = useCallback(() => {
if (!measurement) return if (!measurement) return
@@ -655,7 +656,8 @@ export function Outer({
[context.measurement, frame.height, insets, translationSV], [context.measurement, frame.height, insets, translationSV],
) )
const menuContext = useMemo(() => ({align}), [align]) const xOffset = (flatten(style)?.marginLeft as number) ?? 0
const menuContext = useMemo(() => ({align, xOffset}), [align, xOffset])
if (!context.isOpen || !context.measurement) return null if (!context.isOpen || !context.measurement) return null
@@ -763,7 +765,7 @@ export function Item({
onOut: onPressOut, onOut: onPressOut,
} = useInteractionState() } = useInteractionState()
const id = useId() const id = useId()
const {align} = useContextMenuMenuContext() const {align, xOffset: menuXOffset} = useContextMenuMenuContext()
const {close, measurement, registerHoverable} = context const {close, measurement, registerHoverable} = context
@@ -779,8 +781,8 @@ export function Item({
const xOffset = position const xOffset = position
? position.x ? position.x
: align === 'left' : align === 'left'
? measurement.x ? measurement.x + menuXOffset
: measurement.x + measurement.width - layout.width : measurement.x + measurement.width - layout.width - menuXOffset
registerHoverable( registerHoverable(
id, id,
@@ -796,7 +798,16 @@ export function Item({
}, },
) )
}, },
[id, measurement, registerHoverable, close, onPress, align, position], [
id,
measurement,
registerHoverable,
close,
onPress,
align,
menuXOffset,
position,
],
) )
const itemContext = useMemo( const itemContext = useMemo(
+1
View File
@@ -65,6 +65,7 @@ export type ContextType = {
export type MenuContextType = { export type MenuContextType = {
align: 'left' | 'right' align: 'left' | 'right'
xOffset: number
} }
export type ItemContextType = { export type ItemContextType = {
+1
View File
@@ -167,6 +167,7 @@ export function SuggestedFollowsHome() {
return ( return (
<ProfileGrid <ProfileGrid
recId={data?.recId}
isSuggestionsLoading={isLoading} isSuggestionsLoading={isLoading}
profiles={filteredProfiles} profiles={filteredProfiles}
totalProfileCount={allProfiles.length} totalProfileCount={allProfiles.length}
+13 -2
View File
@@ -1,6 +1,11 @@
import {forwardRef, memo, useContext, useMemo} from 'react' import {forwardRef, memo, useContext, useMemo} from 'react'
import {StyleSheet, View, type ViewProps, type ViewStyle} from 'react-native' import {
import {type StyleProp} from 'react-native' type StyleProp,
StyleSheet,
View,
type ViewProps,
type ViewStyle,
} from 'react-native'
import { import {
KeyboardAwareScrollView, KeyboardAwareScrollView,
type KeyboardAwareScrollViewProps, type KeyboardAwareScrollViewProps,
@@ -11,6 +16,7 @@ import Animated, {
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useEnableMinimalShellModeForScreen} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout' import {useShellLayout} from '#/state/shell/shell-layout'
import { import {
atoms as a, atoms as a,
@@ -30,6 +36,7 @@ export * as Header from '#/components/Layout/Header'
export type ScreenProps = React.ComponentProps<typeof View> & { export type ScreenProps = React.ComponentProps<typeof View> & {
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
noInsetTop?: boolean noInsetTop?: boolean
minimalShell?: boolean
} }
/** /**
@@ -38,9 +45,13 @@ export type ScreenProps = React.ComponentProps<typeof View> & {
export const Screen = memo(function Screen({ export const Screen = memo(function Screen({
style, style,
noInsetTop, noInsetTop,
minimalShell = false,
...props ...props
}: ScreenProps) { }: ScreenProps) {
const {top} = useSafeAreaInsets() const {top} = useSafeAreaInsets()
useEnableMinimalShellModeForScreen({enabled: minimalShell})
return ( return (
<> <>
{IS_WEB && <WebCenterBorders />} {IS_WEB && <WebCenterBorders />}
+6 -6
View File
@@ -201,7 +201,7 @@ export function AvatarPlaceholder({size = 40}: {size?: number}) {
<View <View
style={[ style={[
a.rounded_full, a.rounded_full,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_50,
{ {
width: size, width: size,
height: size, height: size,
@@ -348,7 +348,7 @@ export function NameAndHandlePlaceholder() {
<View <View
style={[ style={[
a.rounded_xs, a.rounded_xs,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_50,
{ {
width: '60%', width: '60%',
height: 14, height: 14,
@@ -359,7 +359,7 @@ export function NameAndHandlePlaceholder() {
<View <View
style={[ style={[
a.rounded_xs, a.rounded_xs,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_50,
{ {
width: '40%', width: '40%',
height: 10, height: 10,
@@ -377,7 +377,7 @@ export function NamePlaceholder({style}: ViewStyleProp) {
<View <View
style={[ style={[
a.rounded_xs, a.rounded_xs,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_50,
{ {
width: '60%', width: '60%',
height: 14, height: 14,
@@ -439,7 +439,7 @@ export function DescriptionPlaceholder({
style={[ style={[
a.rounded_xs, a.rounded_xs,
a.w_full, a.w_full,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_50,
{height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'}, {height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
]} ]}
/> />
@@ -600,7 +600,7 @@ export function FollowButtonPlaceholder({style}: ViewStyleProp) {
<View <View
style={[ style={[
a.rounded_sm, a.rounded_sm,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_50,
a.w_full, a.w_full,
{ {
height: 33, height: 33,
@@ -249,6 +249,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
moderationOpts={moderationOpts!} moderationOpts={moderationOpts!}
noBorder={index === 0} noBorder={index === 0}
position={index} position={index}
recSource={hasSearchText ? 'Search' : undefined}
recId={recIdForLogging} recId={recIdForLogging}
isGuide={isGuide} isGuide={isGuide}
/> />
@@ -264,7 +265,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
return null return null
} }
}, },
[moderationOpts, recIdForLogging, isGuide], [moderationOpts, hasSearchText, recIdForLogging, isGuide],
) )
// Track seen profiles // Track seen profiles
@@ -286,6 +287,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
) )
ax.metric('suggestedUser:seen', { ax.metric('suggestedUser:seen', {
logContext: isGuide ? 'ProgressGuide' : 'SeeMoreSuggestedUsers', logContext: isGuide ? 'ProgressGuide' : 'SeeMoreSuggestedUsers',
recSource: hasSearchText ? 'Search' : undefined,
recId: recIdForLogging, recId: recIdForLogging,
position: position !== -1 ? position : 0, position: position !== -1 ? position : 0,
suggestedDid: item.profile.did, suggestedDid: item.profile.did,
@@ -548,6 +550,7 @@ let FollowProfileCard = ({
moderationOpts, moderationOpts,
noBorder, noBorder,
position, position,
recSource,
recId, recId,
isGuide, isGuide,
}: { }: {
@@ -555,6 +558,7 @@ let FollowProfileCard = ({
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
noBorder?: boolean noBorder?: boolean
position: number position: number
recSource?: 'Search'
recId?: string recId?: string
isGuide: boolean isGuide: boolean
}): React.ReactNode => { }): React.ReactNode => {
@@ -564,6 +568,7 @@ let FollowProfileCard = ({
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
noBorder={noBorder} noBorder={noBorder}
position={position} position={position}
recSource={recSource}
recId={recId} recId={recId}
isGuide={isGuide} isGuide={isGuide}
/> />
@@ -577,6 +582,7 @@ function FollowProfileCardInner({
onFollow, onFollow,
noBorder, noBorder,
position, position,
recSource,
recId, recId,
isGuide, isGuide,
}: { }: {
@@ -585,6 +591,7 @@ function FollowProfileCardInner({
onFollow?: () => void onFollow?: () => void
noBorder?: boolean noBorder?: boolean
position: number position: number
recSource?: 'Search'
recId?: string recId?: string
isGuide: boolean isGuide: boolean
}) { }) {
@@ -625,6 +632,7 @@ function FollowProfileCardInner({
? 'ProgressGuide' ? 'ProgressGuide'
: 'SeeMoreSuggestedUsers', : 'SeeMoreSuggestedUsers',
location: 'Card', location: 'Card',
recSource,
recId, recId,
position, position,
suggestedDid: profile.did, suggestedDid: profile.did,
+22 -5
View File
@@ -3,7 +3,7 @@ import {type GestureResponderEvent, View} from 'react-native'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {atoms as a, useTheme, type ViewStyleProp, web} from '#/alf' import {atoms as a, type TextStyleProp, useTheme, web} from '#/alf'
import { import {
Button, Button,
type ButtonColor, type ButtonColor,
@@ -34,6 +34,8 @@ export function Outer({
control, control,
testID, testID,
nativeOptions, nativeOptions,
webOptions,
onClose,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
testID?: string testID?: string
@@ -41,6 +43,13 @@ export function Outer({
* Native-specific options for the prompt. Extends `BottomSheetViewProps` * Native-specific options for the prompt. Extends `BottomSheetViewProps`
*/ */
nativeOptions?: Omit<BottomSheetViewProps, 'children'> nativeOptions?: Omit<BottomSheetViewProps, 'children'>
/**
* Web-specific options for the prompt
*/
webOptions?: {
onBackgroundPress?: (e: GestureResponderEvent) => void
}
onClose?: () => void
}>) { }>) {
const titleId = useId() const titleId = useId()
const descriptionId = useId() const descriptionId = useId()
@@ -54,7 +63,8 @@ export function Outer({
<Dialog.Outer <Dialog.Outer
control={control} control={control}
testID={testID} testID={testID}
webOptions={{alignCenter: true}} onClose={onClose}
webOptions={{alignCenter: true, ...webOptions}}
nativeOptions={{preventExpansion: true, ...nativeOptions}}> nativeOptions={{preventExpansion: true, ...nativeOptions}}>
<Dialog.Handle /> <Dialog.Handle />
<Context.Provider value={context}> <Context.Provider value={context}>
@@ -72,7 +82,7 @@ export function Outer({
export function TitleText({ export function TitleText({
children, children,
style, style,
}: React.PropsWithChildren<ViewStyleProp>) { }: React.PropsWithChildren<TextStyleProp>) {
const {titleId} = useContext(Context) const {titleId} = useContext(Context)
return ( return (
<Text <Text
@@ -93,14 +103,21 @@ export function TitleText({
export function DescriptionText({ export function DescriptionText({
children, children,
selectable, selectable,
}: React.PropsWithChildren<{selectable?: boolean}>) { style,
}: React.PropsWithChildren<{selectable?: boolean} & TextStyleProp>) {
const t = useTheme() const t = useTheme()
const {descriptionId} = useContext(Context) const {descriptionId} = useContext(Context)
return ( return (
<Text <Text
nativeID={descriptionId} nativeID={descriptionId}
selectable={selectable} selectable={selectable}
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high, a.pb_lg]}> style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_high,
a.pb_lg,
style,
]}>
{children} {children}
</Text> </Text>
) )
@@ -512,7 +512,7 @@ function ExistingChatCard({
]}> ]}>
<ProfileCard.Header> <ProfileCard.Header>
{convo.kind === 'group' ? ( {convo.kind === 'group' ? (
<AvatarBubbles profiles={convo.members} size="small" /> <AvatarBubbles profiles={convo.members} size={40} />
) : ( ) : (
<ProfileCard.Avatar <ProfileCard.Avatar
profile={convo.primaryMember} profile={convo.primaryMember}
+7 -1
View File
@@ -4,23 +4,29 @@ import {useLingui} from '@lingui/react/macro'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {MessageContextMenu} from '#/components/dms/MessageContextMenu' import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
import type * as bsky from '#/types/bsky'
export function ActionsWrapper({ export function ActionsWrapper({
message, message,
isFromSelf, isFromSelf,
senderProfile,
children, children,
onTap, onTap,
}: { }: {
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
hasReactions?: boolean hasReactions?: boolean
isFromSelf: boolean isFromSelf: boolean
senderProfile?: bsky.profile.AnyProfileView
children: React.ReactNode children: React.ReactNode
onTap?: () => void onTap?: () => void
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
return ( return (
<MessageContextMenu message={message} onTap={onTap}> <MessageContextMenu
message={message}
senderProfile={senderProfile}
onTap={onTap}>
{trigger => {trigger =>
// will always be true, since this file is platform split // will always be true, since this file is platform split
trigger.IS_NATIVE && ( trigger.IS_NATIVE && (
+4 -1
View File
@@ -10,6 +10,7 @@ import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import type * as bsky from '#/types/bsky'
import {EmojiReactionPicker} from './EmojiReactionPicker' import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util' import {hasReachedReactionLimit} from './util'
@@ -17,12 +18,14 @@ export function ActionsWrapper({
message, message,
hasReactions, hasReactions,
isFromSelf, isFromSelf,
senderProfile,
children, children,
onTap, onTap,
}: { }: {
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
hasReactions?: boolean hasReactions?: boolean
isFromSelf: boolean isFromSelf: boolean
senderProfile?: bsky.profile.AnyProfileView
children: React.ReactNode children: React.ReactNode
onTap?: () => void onTap?: () => void
}) { }) {
@@ -114,7 +117,7 @@ export function ActionsWrapper({
) )
}} }}
</EmojiReactionPicker> </EmojiReactionPicker>
<MessageContextMenu message={message}> <MessageContextMenu message={message} senderProfile={senderProfile}>
{({props, state, IS_NATIVE, control}) => { {({props, state, IS_NATIVE, control}) => {
// always false, file is platform split // always false, file is platform split
if (IS_NATIVE) return null if (IS_NATIVE) return null
+94 -35
View File
@@ -11,16 +11,18 @@ import {Trans, useLingui} from '@lingui/react/macro'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows' import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {type ListMethods} from '#/view/com/util/List' import {type ListMethods} from '#/view/com/util/List'
import {android, atoms as a, native, useTheme, web} from '#/alf' import {android, atoms as a, native, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {canBeMessaged} from '#/components/dms/util' import {canBeMessaged, type ConvoWithDetails} from '#/components/dms/util'
import * as Toggle from '#/components/forms/Toggle' import * as Toggle from '#/components/forms/Toggle'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_NATIVE, IS_WEB} from '#/env' import {IS_NATIVE, IS_WEB} from '#/env'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
@@ -54,12 +56,12 @@ type PlaceholderItem = {
key: string key: string
} }
type ErrorItem = { type LoadingItem = {
type: 'error' type: 'loading'
key: string key: string
} }
type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | ErrorItem type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | LoadingItem
export type State = { export type State = {
groupChatDids: string[] groupChatDids: string[]
@@ -98,30 +100,46 @@ function reducer(state: State, action: Action): State {
} }
export function AddMembersFlow({ export function AddMembersFlow({
convo,
title, title,
onAddMembers, onAddMembers,
}: { }: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
title: string title: string
onAddMembers: (dids: string[]) => void onAddMembers: (
dids: string[],
profiles: bsky.profile.AnyProfileView[],
) => void
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const {currentAccount} = useSession()
const control = Dialog.useDialogContext() const control = Dialog.useDialogContext()
const [headerHeight, setHeaderHeight] = useState(0) const [headerHeight, setHeaderHeight] = useState(0)
const [footerHeight, setFooterHeight] = useState(0) const [footerHeight, setFooterHeight] = useState(0)
const listRef = useRef<ListMethods>(null)
const {currentAccount} = useSession()
const inputRef = useRef<TextInput>(null)
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const listRef = useRef<ListMethods>(null)
const inputRef = useRef<TextInput>(null)
const { const {
data: results, data: autocompleteResults,
isError, isError,
isFetching, isFetching: isAutocompleteFetching,
} = useActorAutocompleteQuery(searchText, true, 12) } = useActorAutocompleteQuery(searchText, true, 12)
const {data: follows} = useProfileFollowsQuery(currentAccount?.did) const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
const {data: memberListData = [], isPending: isMemberListPending} =
useListConvoMembersQuery({
convoId: convo.view.id,
placeholderData: convo.members,
})
const memberDidSet = useMemo(
() => new Set(memberListData.map(profile => profile.did)),
[memberListData],
)
const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, { const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, {
groupChatDids: [], groupChatDids: [],
@@ -142,8 +160,13 @@ export function AddMembersFlow({
[groupChatDids, groupChatProfiles], [groupChatDids, groupChatProfiles],
) )
const items = useMemo(() => { const items = useMemo<Item[]>(() => {
let _items: Item[] = [] if (isMemberListPending) {
// Still fetching chat member DIDs for filtering, so force the loading state.
return []
}
const _items: Item[] = []
if (isError) { if (isError) {
_items.push({ _items.push({
@@ -152,9 +175,13 @@ export function AddMembersFlow({
message: l`We’re having network issues, try again`, message: l`We’re having network issues, try again`,
}) })
} else if (searchText.length) { } else if (searchText.length) {
if (results?.length) { if (autocompleteResults?.length) {
for (const profile of results) { for (const profile of autocompleteResults) {
if (profile.did === currentAccount?.did) continue if (
profile.did === currentAccount?.did ||
memberDidSet.has(profile.did)
)
continue
_items.push({ _items.push({
type: 'profile', type: 'profile',
key: profile.did, key: profile.did,
@@ -162,18 +189,11 @@ export function AddMembersFlow({
}) })
} }
_items = _items.sort(item => { _items.sort(item => {
return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1
}) })
} }
} else { } else {
const placeholders: Item[] = Array(10)
.fill(0)
.map((__, i) => ({
type: 'placeholder',
key: i + '',
}))
if (follows) { if (follows) {
for (const page of follows.pages) { for (const page of follows.pages) {
for (const profile of page.follows) { for (const profile of page.follows) {
@@ -185,11 +205,13 @@ export function AddMembersFlow({
} }
} }
_items = _items.sort(item => { _items.sort(item => {
return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1
}) })
} else { } else {
_items.push(...placeholders) for (let i = 0; i < 10; i++) {
_items.push({type: 'placeholder', key: i + ''})
}
} }
} }
@@ -201,20 +223,39 @@ export function AddMembersFlow({
}) })
} }
return _items if (searchText && isAutocompleteFetching && _items.length > 0) {
}, [isError, searchText, l, results, currentAccount?.did, follows]) // Stale results are still showing while autocomplete refetches -
// append an inline indicator so the user sees that work is happening.
_items.push({type: 'loading', key: 'loading'})
} else if (
searchText &&
!isAutocompleteFetching &&
!_items.length &&
!isError
) {
_items.push({type: 'empty', key: 'empty', message: l`No results`})
}
if (searchText && !isFetching && !items.length && !isError) { return _items
items.push({type: 'empty', key: 'empty', message: l`No results`}) }, [
} autocompleteResults,
currentAccount?.did,
follows,
isAutocompleteFetching,
isError,
isMemberListPending,
l,
memberDidSet,
searchText,
])
const handlePressBack = useCallback(() => { const handlePressBack = useCallback(() => {
control.close() control.close()
}, [control]) }, [control])
const handlePressAdd = useCallback(() => { const handlePressAdd = useCallback(() => {
onAddMembers(groupChatDids) onAddMembers(groupChatDids, groupChatProfiles)
}, [groupChatDids, onAddMembers]) }, [groupChatDids, groupChatProfiles, onAddMembers])
const renderItems = useCallback( const renderItems = useCallback(
({item}: {item: Item}) => { ({item}: {item: Item}) => {
@@ -234,6 +275,13 @@ export function AddMembersFlow({
case 'placeholder': { case 'placeholder': {
return <ProfileCardSkeleton key={item.key} /> return <ProfileCardSkeleton key={item.key} />
} }
case 'loading': {
return (
<View style={[a.px_lg, a.py_xl, a.align_center]}>
<Loader size="lg" />
</View>
)
}
case 'empty': { case 'empty': {
return <EmptyMemberList key={item.key} message={item.message} /> return <EmptyMemberList key={item.key} message={item.message} />
} }
@@ -427,12 +475,24 @@ export function AddMembersFlow({
renderItem={renderItems} renderItem={renderItems}
ListHeaderComponent={listHeader} ListHeaderComponent={listHeader}
stickyHeaderIndices={[0]} stickyHeaderIndices={[0]}
ListEmptyComponent={
isMemberListPending || isAutocompleteFetching ? (
<View style={[a.flex_1, a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
) : null
}
keyExtractor={(item: Item) => item.key} keyExtractor={(item: Item) => item.key}
style={[ style={[
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
native({height: '100%'}), native({height: '100%'}),
]} ]}
webInnerContentContainerStyle={[a.py_0, {paddingBottom: footerHeight}]} contentContainerStyle={items.length === 0 ? {flexGrow: 1} : undefined}
webInnerContentContainerStyle={[
a.py_0,
{paddingBottom: footerHeight},
items.length === 0 && {flexGrow: 1},
]}
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}} scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}}
keyboardDismissMode="on-drag" keyboardDismissMode="on-drag"
@@ -448,7 +508,6 @@ export function AddMembersFlow({
onPress={handlePressBack}> onPress={handlePressBack}>
<ButtonIcon icon={ArrowLeftIcon} size="md" /> <ButtonIcon icon={ArrowLeftIcon} size="md" />
<ButtonText> <ButtonText>
{' '}
<Trans>Back</Trans> <Trans>Back</Trans>
</ButtonText> </ButtonText>
</Button> </Button>
+2 -3
View File
@@ -4,7 +4,7 @@ import {Trans, useLingui} from '@lingui/react/macro'
import {subDays} from 'date-fns' import {subDays} from 'date-fns'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Text} from '../Typography' import {Text} from '#/components/Typography'
import {localDateString} from './util' import {localDateString} from './util'
const timeFormatter = new Intl.DateTimeFormat(undefined, { const timeFormatter = new Intl.DateTimeFormat(undefined, {
@@ -56,12 +56,11 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
} }
return ( return (
<View style={[a.w_full, a.my_sm]}> <View style={[a.w_full, a.mt_md]}>
<Text <Text
style={[ style={[
a.text_xs, a.text_xs,
a.text_center, a.text_center,
t.atoms.bg,
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
a.px_md, a.px_md,
]}> ]}>
+6 -3
View File
@@ -33,7 +33,7 @@ export function EmojiReactionPicker({
const t = useTheme() const t = useTheme()
const isFromSelf = message.sender?.did === currentAccount?.did const isFromSelf = message.sender?.did === currentAccount?.did
const {measurement, close} = useContextMenuContext() const {measurement, close} = useContextMenuContext()
const {align} = useContextMenuMenuContext() const {align, xOffset} = useContextMenuMenuContext()
const [layout, setLayout] = useState({width: 0, height: 0}) const [layout, setLayout] = useState({width: 0, height: 0})
const {width: screenWidth} = useWindowDimensions() const {width: screenWidth} = useWindowDimensions()
@@ -44,12 +44,15 @@ export function EmojiReactionPicker({
const position = useMemo(() => { const position = useMemo(() => {
return { return {
x: align === 'left' ? 12 : screenWidth - layout.width - 12, x:
align === 'left'
? (measurement?.x ?? 0) + xOffset
: (measurement?.x ?? 0) + (measurement?.width ?? 0) - layout.width,
y: (measurement?.y ?? 0) - tokens.space.xs - layout.height, y: (measurement?.y ?? 0) - tokens.space.xs - layout.height,
height: layout.height, height: layout.height,
width: layout.width, width: layout.width,
} }
}, [measurement, align, screenWidth, layout]) }, [measurement, align, xOffset, screenWidth, layout])
const limitReacted = hasReachedReactionLimit(message, currentAccount?.did) const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
+6 -5
View File
@@ -25,15 +25,18 @@ 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'
import type * as bsky from '#/types/bsky'
import {EmojiReactionPicker} from './EmojiReactionPicker' import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util' import {hasReachedReactionLimit} from './util'
export let MessageContextMenu = ({ export let MessageContextMenu = ({
message, message,
senderProfile,
children, children,
onTap, onTap,
}: { }: {
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
senderProfile?: bsky.profile.AnyProfileView
children: TriggerProps['children'] children: TriggerProps['children']
onTap?: () => void onTap?: () => void
}): React.ReactNode => { }): React.ReactNode => {
@@ -110,9 +113,7 @@ export let MessageContextMenu = ({
[l, convo, message, currentAccount?.did], [l, convo, message, currentAccount?.did],
) )
const sender = convo.convo.members.find( const sender = senderProfile
member => member.did === message.sender.did,
)
return ( return (
<> <>
@@ -183,7 +184,7 @@ export let MessageContextMenu = ({
control={reportControl} control={reportControl}
subject={{ subject={{
view: 'message', view: 'message',
convoId: convo.convo.id, convoId: convo.convo.view.id,
message, message,
}} }}
onAfterSubmit={() => { onAfterSubmit={() => {
@@ -197,7 +198,7 @@ export let MessageContextMenu = ({
control={blockOrDeleteControl} control={blockOrDeleteControl}
currentScreen="conversation" currentScreen="conversation"
params={{ params={{
convoId: convo.convo.id, convoId: convo.convo.view.id,
message, message,
}} }}
/> />
+106 -93
View File
@@ -1,4 +1,4 @@
import {memo, useCallback, useEffect, useMemo, useRef} from 'react' import {memo, useEffect, useMemo, useRef} from 'react'
import { import {
type GestureResponderEvent, type GestureResponderEvent,
LayoutAnimation, LayoutAnimation,
@@ -28,10 +28,8 @@ import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useConvoActive} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types' import {type ConvoItem} from '#/state/messages/convo/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
@@ -44,7 +42,6 @@ import {InlineLinkText, Link} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {DateDivider} from './DateDivider' import {DateDivider} from './DateDivider'
import {useDateDividerToggle} from './DateDividerToggle' import {useDateDividerToggle} from './DateDividerToggle'
import {MessageItemEmbed} from './MessageItemEmbed' import {MessageItemEmbed} from './MessageItemEmbed'
@@ -59,7 +56,9 @@ const DISPLAY_NAME_INSET = 22
const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000 const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000 const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
function isWithinCluster({ const TAP_AND_DRAG_DELAY_MS = 100
function isWithinClusterBoundary({
isPending, isPending,
adjacentMessage, adjacentMessage,
isFromSameSender, isFromSameSender,
@@ -92,62 +91,56 @@ function isWithinCluster({
let MessageItem = ({ let MessageItem = ({
item, item,
isGroupChat = false, isGroupChat = false,
profile,
}: { }: {
item: ConvoItem & {type: 'message' | 'pending-message'} item: ConvoItem & {type: 'message' | 'pending-message'}
isGroupChat?: boolean isGroupChat?: boolean
profile?: bsky.profile.AnyProfileView
}): React.ReactNode => { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {t: l} = useLingui() const {t: l} = useLingui()
const {convo} = useConvoActive()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const profile = item.relatedProfiles.get(item.message.sender.did)
const reactionsControl = useDialogControl() const reactionsControl = useDialogControl()
const reactionTapRef = useRef(false) const reactionTapRef = useRef(false)
const {message, nextMessage, prevMessage} = item const {message, nextMessage, prevMessage} = item
const isPending = item.type === 'pending-message' const isPending = item.type === 'pending-message'
const displayName = sanitizeDisplayName( const displayName = profile ? createSanitizedDisplayName(profile) : null
profile?.displayName || sanitizeHandle(profile?.handle ?? ''),
)
const isFromSelf = message.sender?.did === currentAccount?.did const isFromSelf =
message.sender?.did != null && message.sender.did === currentAccount?.did
const prevIsMessage = ChatBskyConvoDefs.isMessageView(prevMessage) const prevIsMessage = ChatBskyConvoDefs.isMessageView(prevMessage)
const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage) const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage)
const isPrevFromSameSender = const isPrevFromSameSender =
prevIsMessage && prevMessage.sender?.did === message.sender?.did prevIsMessage &&
prevMessage.sender?.did === message.sender?.did &&
message.sender?.did != null
const isNextFromSameSender = const isNextFromSameSender =
nextIsMessage && nextMessage.sender?.did === message.sender?.did nextIsMessage &&
nextMessage.sender?.did === message.sender?.did &&
message.sender?.did != null
const isFirstInCluster = useMemo( const isFirstInCluster = isWithinClusterBoundary({
() => isPending,
isWithinCluster({ adjacentMessage: prevMessage,
isPending, isFromSameSender: isPrevFromSameSender,
adjacentMessage: prevMessage, currentSentAt: message.sentAt,
isFromSameSender: isPrevFromSameSender, direction: 'prev',
currentSentAt: message.sentAt, })
direction: 'prev',
}),
[isPending, prevMessage, isPrevFromSameSender, message.sentAt],
)
const isLastInCluster = useMemo( const isLastInCluster = isWithinClusterBoundary({
() => isPending,
isWithinCluster({ adjacentMessage: nextMessage,
isPending, isFromSameSender: isNextFromSameSender,
adjacentMessage: nextMessage, currentSentAt: message.sentAt,
isFromSameSender: isNextFromSameSender, direction: 'next',
currentSentAt: message.sentAt, })
direction: 'next',
}),
[isPending, nextMessage, isNextFromSameSender, message.sentAt],
)
const hasLargeGapFromPrev = const hasLargeGapFromPrev =
!ChatBskyConvoDefs.isMessageView(prevMessage) || !ChatBskyConvoDefs.isMessageView(prevMessage) ||
@@ -159,7 +152,6 @@ let MessageItem = ({
const isDateDividerToggled = isDividerToggled(message.id) const isDateDividerToggled = isDividerToggled(message.id)
const isNextDateDividerToggled = const isNextDateDividerToggled =
nextMessage != null && isDividerToggled(nextMessage.id) nextMessage != null && isDividerToggled(nextMessage.id)
const showDateDivider = hasLargeGapFromPrev
const effectiveFirstInCluster = isFirstInCluster || isDateDividerToggled const effectiveFirstInCluster = isFirstInCluster || isDateDividerToggled
const effectiveLastInCluster = isLastInCluster || isNextDateDividerToggled const effectiveLastInCluster = isLastInCluster || isNextDateDividerToggled
@@ -168,18 +160,20 @@ let MessageItem = ({
isInCluster && !effectiveFirstInCluster && !effectiveLastInCluster isInCluster && !effectiveFirstInCluster && !effectiveLastInCluster
const hasReactions = message.reactions && message.reactions.length > 0 const hasReactions = message.reactions && message.reactions.length > 0
const prevHasReactions =
prevIsMessage && prevMessage.reactions && prevMessage.reactions.length > 0
const squaredBottomCorner = const squaredBottomCorner =
!hasReactions && !hasReactions &&
isInCluster && isInCluster &&
(isInMiddleOfCluster || effectiveFirstInCluster) (isInMiddleOfCluster || effectiveFirstInCluster)
const squaredTopCorner = const squaredTopCorner =
isInCluster && (isInMiddleOfCluster || effectiveLastInCluster) !prevHasReactions &&
isInCluster &&
(isInMiddleOfCluster || effectiveLastInCluster)
const pendingColor = t.palette.primary_300 const pendingColor = t.palette.primary_300
const rt = useMemo(() => { const rt = new RichTextAPI({text: message.text, facets: message.facets})
return new RichTextAPI({text: message.text, facets: message.facets})
}, [message.text, message.facets])
const hasEmbedAndText = const hasEmbedAndText =
AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0 AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0
@@ -219,27 +213,26 @@ let MessageItem = ({
}, },
) )
const avatar = profile ? ( const avatar =
<Link profile && moderationOpts ? (
label={l`${sanitizeDisplayName( <Link
profile.displayName || sanitizeHandle(profile.handle), label={l`${createSanitizedDisplayName(profile)}’s avatar`}
)}’s avatar`} accessibilityHint={l`Opens this profile`}
accessibilityHint={l`Opens this profile`} to={makeProfileLink({
to={makeProfileLink({ did: profile.did,
did: profile.did, handle: profile.handle,
handle: profile.handle, })}
})} onPress={() => unstableCacheProfileView(queryClient, profile)}>
onPress={() => unstableCacheProfileView(queryClient, profile)}> <ProfileCard.Avatar
<ProfileCard.Avatar profile={profile}
profile={profile} size={AVATAR_SIZE}
size={AVATAR_SIZE} moderationOpts={moderationOpts}
moderationOpts={moderationOpts!} disabledPreview
disabledPreview />
/> </Link>
</Link> ) : (
) : ( <ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} /> )
)
const groupedReactions = useMemo(() => { const groupedReactions = useMemo(() => {
const reactions = message.reactions ?? [] const reactions = message.reactions ?? []
@@ -281,11 +274,9 @@ let MessageItem = ({
return l`You reacted ${reaction.value}` return l`You reacted ${reaction.value}`
} else { } else {
const senderDid = reaction.sender.did const senderDid = reaction.sender.did
const sender = convo.members.find(member => member.did === senderDid) const memberSender = item.relatedProfiles.get(senderDid)
if (sender) { if (memberSender) {
return l`${sanitizeDisplayName( return l`${createSanitizedDisplayName(memberSender)} reacted ${reaction.value}`
sender.displayName || sender.handle,
)} reacted ${reaction.value}`
} }
return l`Someone reacted ${reaction.value}` return l`Someone reacted ${reaction.value}`
} }
@@ -294,7 +285,13 @@ let MessageItem = ({
one: '# person', one: '# person',
other: '# people', other: '# people',
})} reacted – ${groupedReactions.map(g => g.value).join(' ')}` })} reacted – ${groupedReactions.map(g => g.value).join(' ')}`
}, [reactions, groupedReactions, currentAccount?.did, convo.members, l]) }, [
reactions,
groupedReactions,
currentAccount?.did,
item.relatedProfiles,
l,
])
const appliedReactions = ( const appliedReactions = (
<LayoutAnimationConfig skipEntering skipExiting> <LayoutAnimationConfig skipEntering skipExiting>
@@ -337,14 +334,16 @@ let MessageItem = ({
// Include a delay here to account for tap-and-drag before release. // Include a delay here to account for tap-and-drag before release.
setTimeout(() => { setTimeout(() => {
reactionTapRef.current = false reactionTapRef.current = false
}, 100) }, TAP_AND_DRAG_DELAY_MS)
}} }}
onPress={() => (isGroupChat ? reactionsControl.open() : undefined)}> onPress={isGroupChat ? reactionsControl.open : 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))}
exiting={ exiting={
groupedReactions.length > 1 && native(ZoomOut.delay(200)) groupedReactions.length > 1
? native(ZoomOut.delay(200))
: undefined
} }
layout={native(LinearTransition.delay(300))} layout={native(LinearTransition.delay(300))}
key={group.value} key={group.value}
@@ -377,7 +376,7 @@ let MessageItem = ({
) : null} ) : null}
<ReactionsDialog <ReactionsDialog
control={reactionsControl} control={reactionsControl}
members={convo.members} relatedProfiles={item.relatedProfiles}
message={message} message={message}
reactions={message.reactions} reactions={message.reactions}
groupedReactions={groupedReactions} groupedReactions={groupedReactions}
@@ -393,13 +392,14 @@ let MessageItem = ({
return ( return (
<> <>
{(showDateDivider || isDateDividerToggled) && ( <LayoutAnimationConfig skipExiting skipEntering>
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}> {(hasLargeGapFromPrev || isDateDividerToggled) && (
<DateDivider date={message.sentAt} /> <Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
</Animated.View> <DateDivider date={message.sentAt} />
)} </Animated.View>
<View )}
style={[messageInset, isFirstInCluster && !showDateDivider && a.mt_sm]}> </LayoutAnimationConfig>
<View style={[messageInset, effectiveFirstInCluster && a.mt_md]}>
<View style={[a.relative]}> <View style={[a.relative]}>
{showAvatar ? ( {showAvatar ? (
<View <View
@@ -419,7 +419,7 @@ let MessageItem = ({
a.flex_grow, a.flex_grow,
!isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE}, !isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE},
]}> ]}>
{showDisplayName ? ( {displayName && showDisplayName ? (
<Text <Text
style={[ style={[
a.text_xs, a.text_xs,
@@ -437,6 +437,7 @@ let MessageItem = ({
hasReactions={hasReactions} hasReactions={hasReactions}
isFromSelf={isFromSelf} isFromSelf={isFromSelf}
message={message} message={message}
senderProfile={profile}
onTap={() => { onTap={() => {
if (reactionTapRef.current) return if (reactionTapRef.current) return
if (!hasLargeGapFromPrev) { if (!hasLargeGapFromPrev) {
@@ -454,7 +455,6 @@ let MessageItem = ({
...(isOnlyEmoji(message.text) ...(isOnlyEmoji(message.text)
? [] ? []
: [ : [
a.rounded_md,
a.rounded_xl, a.rounded_xl,
a.py_sm, a.py_sm,
a.px_md, a.px_md,
@@ -474,7 +474,23 @@ let MessageItem = ({
]}> ]}>
<RichText <RichText
value={rt} value={rt}
style={[a.text_md, isFromSelf && {color: t.palette.white}]} style={[
a.text_md,
isFromSelf && {color: t.palette.white},
// Emoji-only: add top leading to avoid clipping the
// glyph, then pull the bottom up by the same amount so
// the glyph bottom-aligns with the avatar instead of
// sitting above its line-box baseline.
isOnlyEmoji(message.text) && [
a.leading_tight,
// Visually align bottom of the emoji with the avatar
!isFromSelf &&
platform({
android: {marginTop: a.mt_2xs.marginTop},
default: {marginBottom: -a.mb_sm.marginBottom},
}),
],
]}
interactiveStyle={a.underline} interactiveStyle={a.underline}
enableTags enableTags
emojiMultiplier={3} emojiMultiplier={3}
@@ -517,16 +533,13 @@ let MessageItemMetadata = ({
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const handleRetry = useCallback( const handleRetry = (e: GestureResponderEvent) => {
(e: GestureResponderEvent) => { if (item.type === 'pending-message' && item.retry) {
if (item.type === 'pending-message' && item.retry) { e.preventDefault()
e.preventDefault() item.retry()
item.retry() return false
return false }
} }
},
[item],
)
const errorColor = t.palette.negative_400 const errorColor = t.palette.negative_400
-1
View File
@@ -29,7 +29,6 @@ let MessageItemEmbed = ({
<View <View
style={[ style={[
!isFromSelf && a.ml_sm, !isFromSelf && a.ml_sm,
t.atoms.bg,
native({ native({
flexBasis: 0, flexBasis: 0,
width: Math.min(screen.width, 600) / 1.4, width: Math.min(screen.width, 600) / 1.4,
+4 -1
View File
@@ -161,11 +161,13 @@ function GroupHeaderReady({
}) })
} }
const lockStatus = convo.details.lockStatus
return ( return (
<Wrapper <Wrapper
heading={ heading={
<> <>
<AvatarBubbles size="small" profiles={convo.members} /> <AvatarBubbles size={40} profiles={convo.members} />
<Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}> <Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
{convo.details.name} {convo.details.name}
</Text> </Text>
@@ -175,6 +177,7 @@ function GroupHeaderReady({
settings={ settings={
<Button <Button
label={l`Open group chat settings`} label={l`Open group chat settings`}
disabled={lockStatus === 'locked-permanently'}
size="small" size="small"
color="secondary" color="secondary"
shape="round" shape="round"
+4 -4
View File
@@ -7,7 +7,7 @@ import {
View, View,
} from 'react-native' } from 'react-native'
import Animated from 'react-native-reanimated' import Animated from 'react-native-reanimated'
import {type ChatBskyConvoDefs} from '@atproto/api' import {type ChatBskyActorDefs, type ChatBskyConvoDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {HITSLOP_10} from '#/lib/constants' import {HITSLOP_10} from '#/lib/constants'
@@ -33,13 +33,13 @@ type Reaction = {
export function ReactionsDialog({ export function ReactionsDialog({
control, control,
members, relatedProfiles,
message, message,
reactions, reactions,
groupedReactions, groupedReactions,
}: { }: {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
members: bsky.profile.AnyProfileView[] relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
reactions?: ChatBskyConvoDefs.ReactionView[] reactions?: ChatBskyConvoDefs.ReactionView[]
groupedReactions?: Reaction[] groupedReactions?: Reaction[]
@@ -100,7 +100,7 @@ export function ReactionsDialog({
return 0 return 0
}) })
.map(reaction => { .map(reaction => {
const sender = members.find(m => m.did === reaction.sender.did) const sender = relatedProfiles.get(reaction.sender.did)
if (!sender) return null if (!sender) return null
return ( return (
<ReactionRow <ReactionRow
+44
View File
@@ -0,0 +1,44 @@
import {View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {type ConvoItem} from '#/state/messages/convo/types'
import {atoms as a, useTheme} from '#/alf'
import {getSystemMessageInfo} from '#/components/dms/getSystemMessageInfo'
import {Text} from '#/components/Typography'
export function SystemMessageItem({
item,
}: {
item: ConvoItem & {type: 'system-message'}
}) {
const t = useTheme()
const {i18n} = useLingui()
const info = getSystemMessageInfo(item.message.data, item.relatedProfiles)
if (!info) return null
const {Icon, message} = info
return (
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_center,
a.px_md,
a.mt_md,
]}>
<Icon size="xs" style={[a.mr_2xs, t.atoms.text_contrast_medium]} />
<Text
style={[
a.text_xs,
a.text_center,
t.atoms.text_contrast_medium,
{includeFontPadding: false, textAlignVertical: 'center'},
]}>
{i18n._(message)}
</Text>
</View>
)
}
+93
View File
@@ -0,0 +1,93 @@
import {AppBskyEmbedRecord, ChatBskyConvoDefs} from '@atproto/api'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {
postUriToRelativePath,
toBskyAppUrl,
toShortUrl,
} from '#/lib/strings/url-helpers'
export type UserMessageInfo = {
message: string | null
sentAt: string
reportableMessage?: ChatBskyConvoDefs.MessageView
}
export function getMessageInfo({
convo,
currentAccountDid,
i18n,
}: {
convo: ChatBskyConvoDefs.ConvoView
currentAccountDid: string | undefined
i18n: I18n
}): UserMessageInfo | null {
if (!ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
return null
}
const lastMessage = convo.lastMessage
const isFromMe = lastMessage.sender?.did === currentAccountDid
const senderDid = lastMessage.sender?.did
const sender = convo.members.find(m => m.did === senderDid)
const name = sender ? createSanitizedDisplayName(sender) : null
const isGroup = ChatBskyConvoDefs.isGroupConvo(convo.kind)
const reportableMessage = isFromMe ? undefined : lastMessage
const prefix = (message: string) => {
if (isFromMe) {
return i18n._(
msg({
message: `You: ${message}`,
comment: 'When the last message in a chat was made by you.',
}),
)
} else if (isGroup && name) {
return i18n._(
msg({
message: `${name}: ${message}`,
comment:
'When the last message in a group chat came from someone other than you.',
}),
)
}
return message
}
let message: string | null = null
if (lastMessage.text) {
message = prefix(lastMessage.text)
} else if (lastMessage.embed) {
const defaultEmbeddedContentMessage = i18n._(
msg`(contains embedded content)`,
)
if (AppBskyEmbedRecord.isView(lastMessage.embed)) {
const embed = lastMessage.embed
if (AppBskyEmbedRecord.isViewRecord(embed.record)) {
const record = embed.record
const path = postUriToRelativePath(record.uri, {
handle: record.author.handle,
})
const href = path ? toBskyAppUrl(path) : undefined
const short = href ? toShortUrl(href) : defaultEmbeddedContentMessage
message = prefix(short)
} else {
message = prefix(defaultEmbeddedContentMessage)
}
} else {
message = prefix(defaultEmbeddedContentMessage)
}
}
return {
message,
sentAt: lastMessage.sentAt,
reportableMessage,
}
}
+54
View File
@@ -0,0 +1,54 @@
import {ChatBskyConvoDefs} from '@atproto/api'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
export type UserReactionInfo = {
message: string
createdAt: string
}
export function getReactionInfo({
convo,
currentAccountDid,
i18n,
}: {
convo: ChatBskyConvoDefs.ConvoView
currentAccountDid: string | undefined
i18n: I18n
}): UserReactionInfo | null {
if (!ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) {
return null
}
const {reaction, message: reactedTo} = convo.lastReaction
const isFromMe = reaction.sender.did === currentAccountDid
const senderDid = reaction.sender.did
const sender = convo.members.find(m => m.did === senderDid)
const name = sender ? createSanitizedDisplayName(sender) : null
const lastMessageText = reactedTo.text
const fallbackMessage = i18n._(
msg({
message: 'a message',
comment:
'If last message does not contain text, fall back to "{user} reacted to {a message}"',
}),
)
const target = lastMessageText ? `"${lastMessageText}"` : fallbackMessage
let message: string
if (isFromMe) {
message = i18n._(msg`You reacted ${reaction.value} to ${target}`)
} else if (name) {
message = i18n._(msg`${name} reacted ${reaction.value} to ${target}`)
} else {
message = i18n._(msg`Someone reacted ${reaction.value} to ${target}`)
}
return {
message,
createdAt: reaction.createdAt,
}
}
@@ -0,0 +1,89 @@
import {type ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
import {type MessageDescriptor} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as LeaveIcon} from '#/components/icons/ArrowBoxLeft'
import {ArrowBoxRight_Stroke2_Corner3_Rounded as JoinIcon} from '#/components/icons/ArrowBoxRight'
import {
ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon,
ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon,
} from '#/components/icons/ChainLink'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {
Lock_Stroke2_Corner0_Rounded as LockIcon,
Unlock_Stroke2_Corner2_Rounded as UnlockIcon,
} from '#/components/icons/Lock'
import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil'
export type SystemMessageInfo = {
message: MessageDescriptor
Icon: React.ComponentType<SVGIconProps>
}
function getReferredDisplayName(
user: ChatBskyConvoDefs.SystemMessageReferredUser,
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>,
): string | null {
const profile = relatedProfiles.get(user.did)
return profile ? createSanitizedDisplayName(profile) : null
}
export function getSystemMessageInfo(
data: ChatBskyConvoDefs.SystemMessageView['data'],
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>,
): SystemMessageInfo | null {
if (ChatBskyConvoDefs.isSystemMessageDataAddMember(data)) {
const name = getReferredDisplayName(data.member, relatedProfiles)
return {
Icon: JoinIcon,
message: name
? msg`${name} was added to the group`
: msg`Someone was added to the group`,
}
} else if (ChatBskyConvoDefs.isSystemMessageDataRemoveMember(data)) {
const name = getReferredDisplayName(data.member, relatedProfiles)
return {
Icon: LeaveIcon,
message: name
? msg`${name} was removed from the group`
: msg`Someone was removed from the group`,
}
} else if (ChatBskyConvoDefs.isSystemMessageDataMemberJoin(data)) {
const name = getReferredDisplayName(data.member, relatedProfiles)
return {
Icon: JoinIcon,
message: name
? msg`${name} joined the group`
: msg`Someone joined the group`,
}
} else if (ChatBskyConvoDefs.isSystemMessageDataMemberLeave(data)) {
const name = getReferredDisplayName(data.member, relatedProfiles)
return {
Icon: LeaveIcon,
message: name ? msg`${name} left the group` : msg`Someone left the group`,
}
} else if (ChatBskyConvoDefs.isSystemMessageDataLockConvo(data)) {
return {Icon: LockIcon, message: msg`Chat locked`}
} else if (ChatBskyConvoDefs.isSystemMessageDataUnlockConvo(data)) {
return {Icon: UnlockIcon, message: msg`Chat unlocked`}
} else if (ChatBskyConvoDefs.isSystemMessageDataLockConvoPermanently(data)) {
return {Icon: LockIcon, message: msg`Chat locked permanently`}
} else if (ChatBskyConvoDefs.isSystemMessageDataEditGroup(data)) {
return {
Icon: PencilIcon,
message: data.newName
? msg`Chat title changed to ${data.newName}`
: msg`Chat title changed`,
}
} else if (ChatBskyConvoDefs.isSystemMessageDataCreateJoinLink(data)) {
return {Icon: ChainLinkIcon, message: msg`Invite link created`}
} else if (ChatBskyConvoDefs.isSystemMessageDataEditJoinLink(data)) {
return {Icon: ChainLinkIcon, message: msg`Invite link edited`}
} else if (ChatBskyConvoDefs.isSystemMessageDataEnableJoinLink(data)) {
return {Icon: ChainLinkIcon, message: msg`Invite link enabled`}
} else if (ChatBskyConvoDefs.isSystemMessageDataDisableJoinLink(data)) {
return {Icon: ChainLinkBrokenIcon, message: msg`Invite link disabled`}
}
return null
}
+8 -5
View File
@@ -68,13 +68,13 @@ export type DirectConvoMember = ChatBskyActorDefs.ProfileViewBasic & {
export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & ( export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & (
| { | {
kind: 'group' kind: 'group'
details: ChatBskyConvoDefs.GroupConvo details: $Typed<ChatBskyConvoDefs.GroupConvo>
primaryMember: GroupConvoMember // the owner primaryMember: GroupConvoMember // the owner
members: Array<GroupConvoMember> members: Array<GroupConvoMember>
} }
| { | {
kind: 'direct' kind: 'direct'
details: ChatBskyConvoDefs.DirectConvo details: $Typed<ChatBskyConvoDefs.DirectConvo>
primaryMember: DirectConvoMember // the other user primaryMember: DirectConvoMember // the other user
members: Array<DirectConvoMember> members: Array<DirectConvoMember>
} }
@@ -110,14 +110,16 @@ export function parseConvoView(
owner = member as GroupConvoMember owner = member as GroupConvoMember
} }
} else { } else {
throw new Error( logger.warn(
'Expected a GroupConvoMember, got an unknown kind of member', 'Expected a GroupConvoMember, got an unknown kind of member',
) )
return null
} }
} }
if (!owner) { if (!owner) {
throw new Error('No owner found in group convo') logger.warn('No owner found in group convo')
return null
} }
return { return {
@@ -136,7 +138,8 @@ export function parseConvoView(
const otherUser = convoView.members.find(m => m.did !== ownDid) const otherUser = convoView.members.find(m => m.did !== ownDid)
if (!otherUser) { if (!otherUser) {
throw new Error('No other user found in direct convo') logger.warn('No other user found in direct convo')
return null
} }
return { return {
+11 -1
View File
@@ -81,6 +81,7 @@ export type ItemProps = ViewStyleProp & {
isInvalid?: boolean isInvalid?: boolean
children: ((props: ItemState) => React.ReactNode) | React.ReactNode children: ((props: ItemState) => React.ReactNode) | React.ReactNode
hitSlop?: PressableProps['hitSlop'] hitSlop?: PressableProps['hitSlop']
highlightRow?: boolean
} }
export function useItemContext() { export function useItemContext() {
@@ -160,8 +161,11 @@ export function Item({
style, style,
type = 'checkbox', type = 'checkbox',
label, label,
highlightRow,
...rest ...rest
}: ItemProps) { }: ItemProps) {
const t = useTheme()
const { const {
values: selectedValues, values: selectedValues,
type: groupType, type: groupType,
@@ -207,6 +211,12 @@ export function Item({
[name, selected, disabled, hovered, pressed, focused, isInvalid], [name, selected, disabled, hovered, pressed, focused, isInvalid],
) )
const highlightStyle = highlightRow
? selected
? [a.rounded_full, a.p_md, {backgroundColor: t.palette.primary_50}]
: [a.rounded_full, a.p_md]
: null
return ( return (
<ItemContext.Provider value={state}> <ItemContext.Provider value={state}>
<Pressable <Pressable
@@ -232,7 +242,7 @@ export function Item({
onPressOut={onPressOut} onPressOut={onPressOut}
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
style={[a.flex_row, a.align_center, a.gap_sm, style]}> style={[a.flex_row, a.align_center, a.gap_sm, highlightStyle, style]}>
{typeof children === 'function' ? children(state) : children} {typeof children === 'function' ? children(state) : children}
</Pressable> </Pressable>
</ItemContext.Provider> </ItemContext.Provider>
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const ArrowBoxRight_Stroke2_Corner3_Rounded = createSinglePathSVG({
path: 'M17 3a4 4 0 0 1 4 4v10a4 4 0 0 1-4 4h-2a1 1 0 1 1 0-2h2a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2a1 1 0 1 1 0-2h2Zm-6.707 4.793a1 1 0 0 1 1.414 0l3.5 3.5a1 1 0 0 1 0 1.414l-3.5 3.5a1 1 0 1 1-1.414-1.414L12.086 13H4a1 1 0 1 1 0-2h8.086l-1.793-1.793a1 1 0 0 1 0-1.414Z',
})
+4
View File
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
export const ChainLink_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const ChainLink_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M18.535 5.465a5.003 5.003 0 0 0-7.076 0l-.005.005-.752.742a1 1 0 1 1-1.404-1.424l.749-.74a7.003 7.003 0 0 1 9.904 9.905l-.002.003-.737.746a1 1 0 1 1-1.424-1.404l.747-.757a5.003 5.003 0 0 0 0-7.076ZM6.202 9.288a1 1 0 0 1 .01 1.414l-.747.757a5.003 5.003 0 1 0 7.076 7.076l.005-.005.752-.742a1 1 0 1 1 1.404 1.424l-.746.737-.003.002a7.003 7.003 0 0 1-9.904-9.904l.74-.75a1 1 0 0 1 1.413-.009Zm8.505.005a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414-1.414l4-4a1 1 0 0 1 1.414 0Z', path: 'M18.535 5.465a5.003 5.003 0 0 0-7.076 0l-.005.005-.752.742a1 1 0 1 1-1.404-1.424l.749-.74a7.003 7.003 0 0 1 9.904 9.905l-.002.003-.737.746a1 1 0 1 1-1.424-1.404l.747-.757a5.003 5.003 0 0 0 0-7.076ZM6.202 9.288a1 1 0 0 1 .01 1.414l-.747.757a5.003 5.003 0 1 0 7.076 7.076l.005-.005.752-.742a1 1 0 1 1 1.404 1.424l-.746.737-.003.002a7.003 7.003 0 0 1-9.904-9.904l.74-.75a1 1 0 0 1 1.413-.009Zm8.505.005a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414-1.414l4-4a1 1 0 0 1 1.414 0Z',
}) })
export const ChainLinkBroken_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M14.3 23v-1.1a1 1 0 0 1 2 0V23a1 1 0 1 1-2 0Zm5.243-3.457a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM4.788 9.298a1 1 0 0 1 1.424 1.404l-.742.752-.004.005a5.003 5.003 0 1 0 7.075 7.075l.005-.004.752-.742a1 1 0 0 1 1.404 1.424l-.747.736a7.003 7.003 0 1 1-9.904-9.904l.737-.746ZM23 14.3a1 1 0 0 1 0 2h-1.1a1 1 0 1 1 0-2H23ZM10.044 4.05a7.005 7.005 0 0 1 9.905 9.906h0l-.737.746a1 1 0 0 1-1.424-1.404l.742-.752.004-.005a5.003 5.003 0 1 0-7.075-7.075l-.005.004-.752.742a1 1 0 0 1-1.404-1.424l.746-.737ZM2.1 7.7a1 1 0 1 1 0 2H1a1 1 0 0 1 0-2h1.1Zm-.157-5.757a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM7.7 2.1V1a1 1 0 1 1 2 0v1.1a1 1 0 0 1-2 0Z',
})
+7
View File
@@ -5,6 +5,13 @@ export const EditBig_Stroke1_Corner0_Rounded = createSinglePathSVG({
path: 'M19.667 4.458a1 1 0 1 0 0-2v2Zm25 23a1 1 0 0 0-2 0h2ZM3.912 45.543l.454-.891-.454.89Zm-2.33-2.33.89-.455h0l-.89.454Zm39.173 2.33-.454-.891h0l.454.89Zm2.33-2.33-.89-.455.89.454ZM1.581 6.37l-.89-.454h0l.89.454Zm2.331-2.331-.454-.891.454.89ZM14.333 32.79h-1a1 1 0 0 0 1 1v-1Zm.781-8.781.707.707-.707-.707ZM36.562 2.562l-.707-.707v0l.707.707Zm7.543 0-.707.707v0l.707-.707Zm.457.458.707-.707v0l-.707.707Zm0 7.542.707.707-.707-.707ZM23.114 32.01l.707.707-.707-.707Zm12.02 14.114v-1h-25.6v2h25.6v-1ZM1 37.591h1v-25.6H0v25.6h1ZM9.533 3.458v1h10.134v-2H9.533v1Zm34.134 24h-1V37.59h2V27.457h-1ZM9.533 46.124v-1c-1.51 0-2.582 0-3.421-.07-.828-.067-1.34-.195-1.746-.402l-.454.89-.454.892c.735.374 1.54.537 2.491.614.94.077 2.107.076 3.584.076v-1ZM1 37.591H0c0 1.477 0 2.645.076 3.584.078.951.24 1.756.614 2.491l.891-.454.891-.454c-.207-.406-.335-.918-.403-1.746C2.001 40.173 2 39.101 2 37.591H1Zm2.912 7.952.454-.891a4.33 4.33 0 0 1-1.894-1.894l-.89.454-.892.454a6.33 6.33 0 0 0 2.768 2.768l.454-.891Zm31.221.581v1c1.477 0 2.645.001 3.585-.076.95-.078 1.756-.24 2.49-.614l-.453-.891-.454-.891c-.406.207-.919.335-1.746.403-.84.068-1.912.07-3.422.07v1Zm8.534-8.533h-1c0 1.51-.001 2.582-.07 3.421-.067.828-.196 1.34-.403 1.746l.891.454.891.454c.375-.735.537-1.54.615-2.49.076-.94.076-2.108.076-3.585h-1Zm-2.912 7.952.454.89a6.33 6.33 0 0 0 2.767-2.767l-.89-.454-.892-.454a4.33 4.33 0 0 1-1.893 1.894l.454.89ZM1 11.99h1c0-1.51 0-2.582.07-3.422.067-.827.195-1.34.402-1.745l-.89-.454-.892-.454c-.374.734-.536 1.54-.614 2.49C-.001 9.345 0 10.513 0 11.99h1Zm8.533-8.533v-1c-1.477 0-2.645-.001-3.584.076-.951.077-1.756.24-2.49.614l.453.89.454.892c.406-.207.918-.336 1.746-.403.839-.069 1.911-.07 3.421-.07v-1ZM1.581 6.37l.891.454A4.33 4.33 0 0 1 4.366 4.93l-.454-.891-.454-.891A6.33 6.33 0 0 0 .69 5.916l.891.454Zm12.752 19.525h-1v6.896h2v-6.896h-1Zm0 6.896v1h6.896v-2h-6.896v1Zm.781-8.781.707.707L37.27 3.269l-.707-.707-.707-.707-21.448 21.448.707.707Zm28.99-21.448-.706.707.457.458.707-.707.707-.707-.457-.458-.707.707Zm.458 8-.707-.707-21.448 21.448.707.707.707.707L45.27 11.269l-.707-.707Zm0-7.542-.707.707a4.333 4.333 0 0 1 0 6.128l.707.707.707.707a6.333 6.333 0 0 0 0-8.956l-.707.707Zm-8-.458.707.707a4.333 4.333 0 0 1 6.129 0l.707-.707.707-.707a6.333 6.333 0 0 0-8.957 0l.707.707ZM21.23 32.791v1c.972 0 1.905-.386 2.593-1.074l-.708-.707-.707-.707a1.67 1.67 0 0 1-1.178.488v1Zm-6.896-6.896h1c0-.442.176-.866.489-1.178l-.708-.707-.707-.707a3.67 3.67 0 0 0-1.074 2.592h1Z', path: 'M19.667 4.458a1 1 0 1 0 0-2v2Zm25 23a1 1 0 0 0-2 0h2ZM3.912 45.543l.454-.891-.454.89Zm-2.33-2.33.89-.455h0l-.89.454Zm39.173 2.33-.454-.891h0l.454.89Zm2.33-2.33-.89-.455.89.454ZM1.581 6.37l-.89-.454h0l.89.454Zm2.331-2.331-.454-.891.454.89ZM14.333 32.79h-1a1 1 0 0 0 1 1v-1Zm.781-8.781.707.707-.707-.707ZM36.562 2.562l-.707-.707v0l.707.707Zm7.543 0-.707.707v0l.707-.707Zm.457.458.707-.707v0l-.707.707Zm0 7.542.707.707-.707-.707ZM23.114 32.01l.707.707-.707-.707Zm12.02 14.114v-1h-25.6v2h25.6v-1ZM1 37.591h1v-25.6H0v25.6h1ZM9.533 3.458v1h10.134v-2H9.533v1Zm34.134 24h-1V37.59h2V27.457h-1ZM9.533 46.124v-1c-1.51 0-2.582 0-3.421-.07-.828-.067-1.34-.195-1.746-.402l-.454.89-.454.892c.735.374 1.54.537 2.491.614.94.077 2.107.076 3.584.076v-1ZM1 37.591H0c0 1.477 0 2.645.076 3.584.078.951.24 1.756.614 2.491l.891-.454.891-.454c-.207-.406-.335-.918-.403-1.746C2.001 40.173 2 39.101 2 37.591H1Zm2.912 7.952.454-.891a4.33 4.33 0 0 1-1.894-1.894l-.89.454-.892.454a6.33 6.33 0 0 0 2.768 2.768l.454-.891Zm31.221.581v1c1.477 0 2.645.001 3.585-.076.95-.078 1.756-.24 2.49-.614l-.453-.891-.454-.891c-.406.207-.919.335-1.746.403-.84.068-1.912.07-3.422.07v1Zm8.534-8.533h-1c0 1.51-.001 2.582-.07 3.421-.067.828-.196 1.34-.403 1.746l.891.454.891.454c.375-.735.537-1.54.615-2.49.076-.94.076-2.108.076-3.585h-1Zm-2.912 7.952.454.89a6.33 6.33 0 0 0 2.767-2.767l-.89-.454-.892-.454a4.33 4.33 0 0 1-1.893 1.894l.454.89ZM1 11.99h1c0-1.51 0-2.582.07-3.422.067-.827.195-1.34.402-1.745l-.89-.454-.892-.454c-.374.734-.536 1.54-.614 2.49C-.001 9.345 0 10.513 0 11.99h1Zm8.533-8.533v-1c-1.477 0-2.645-.001-3.584.076-.951.077-1.756.24-2.49.614l.453.89.454.892c.406-.207.918-.336 1.746-.403.839-.069 1.911-.07 3.421-.07v-1ZM1.581 6.37l.891.454A4.33 4.33 0 0 1 4.366 4.93l-.454-.891-.454-.891A6.33 6.33 0 0 0 .69 5.916l.891.454Zm12.752 19.525h-1v6.896h2v-6.896h-1Zm0 6.896v1h6.896v-2h-6.896v1Zm.781-8.781.707.707L37.27 3.269l-.707-.707-.707-.707-21.448 21.448.707.707Zm28.99-21.448-.706.707.457.458.707-.707.707-.707-.457-.458-.707.707Zm.458 8-.707-.707-21.448 21.448.707.707.707.707L45.27 11.269l-.707-.707Zm0-7.542-.707.707a4.333 4.333 0 0 1 0 6.128l.707.707.707.707a6.333 6.333 0 0 0 0-8.956l-.707.707Zm-8-.458.707.707a4.333 4.333 0 0 1 6.129 0l.707-.707.707-.707a6.333 6.333 0 0 0-8.957 0l.707.707ZM21.23 32.791v1c.972 0 1.905-.386 2.593-1.074l-.708-.707-.707-.707a1.67 1.67 0 0 1-1.178.488v1Zm-6.896-6.896h1c0-.442.176-.866.489-1.178l-.708-.707-.707-.707a3.67 3.67 0 0 0-1.074 2.592h1Z',
}) })
/**
* @deprecated Use EditBig_Stroke2_Corner2_Rounded
*/
export const EditBig_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const EditBig_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M17.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-9 9A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l9-9ZM10 12.414V14h1.586l8-8L18 4.414l-8 8ZM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Z', path: 'M17.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-9 9A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l9-9ZM10 12.414V14h1.586l8-8L18 4.414l-8 8ZM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Z',
}) })
export const EditBig_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M3 16.8V7.2c0-.544-.001-1.011.03-1.395.033-.395.104-.789.297-1.167a3 3 0 0 1 1.31-1.31c.379-.193.772-.265 1.168-.297C6.188 2.999 6.657 3 7.2 3H11a1 1 0 1 1 0 2H7.2c-.576 0-.949 0-1.232.023-.272.022-.373.06-.422.085a1 1 0 0 0-.437.437c-.025.05-.062.15-.085.422C5.001 6.251 5 6.623 5 7.2v9.6c0 .577.001.95.024 1.232.023.272.06.373.085.422a1 1 0 0 0 .437.437c.05.025.15.063.422.085.283.023.656.024 1.232.024h9.6c.576 0 .949-.001 1.232-.024.272-.022.373-.06.422-.085a1 1 0 0 0 .437-.437c.025-.049.062-.15.085-.422.023-.283.024-.655.024-1.232V13a1 1 0 1 1 2 0v3.8c0 .543.001 1.011-.03 1.395-.033.395-.104.788-.297 1.167a3 3 0 0 1-1.31 1.311c-.379.193-.772.264-1.168.296-.383.031-.852.031-1.395.031H7.2c-.543 0-1.012 0-1.395-.031-.396-.032-.789-.103-1.167-.296a3 3 0 0 1-1.31-1.311c-.194-.379-.265-.772-.298-1.167C3 17.81 3 17.343 3 16.8M16.629 2.957a3 3 0 0 1 4.242 0l.172.171a3 3 0 0 1 0 4.243L13 15.414a2 2 0 0 1-1.414.586H9a1 1 0 0 1-1-1v-2.586A2 2 0 0 1 8.586 11zM10 14h1.586l8.043-8.043a1 1 0 0 0 0-1.414l-.172-.172a1 1 0 0 0-1.414 0L10 12.414z',
})
+4
View File
@@ -7,3 +7,7 @@ export const Lock_Stroke2_Corner0_Rounded = createSinglePathSVG({
export const Lock_Stroke2_Corner2_Rounded = createSinglePathSVG({ export const Lock_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M7 7a5 5 0 0 1 10 0v2a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7Zm0 4a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7Zm8-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z', path: 'M7 7a5 5 0 0 1 10 0v2a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7Zm0 4a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7Zm8-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z',
}) })
export const Unlock_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1zm5 2a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1',
})
@@ -1,5 +1,12 @@
import {createSinglePathSVG} from './TEMPLATE' import {createSinglePathSVG} from './TEMPLATE'
/**
* @deprecated Use SquareBehindSquare_Stroke2_Corner2_Rounded
*/
export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z', path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z',
}) })
export const SquareBehindSquare_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z',
})
+19 -11
View File
@@ -1,17 +1,25 @@
import {useCallback, useInsertionEffect, useRef} from 'react' import {useCallback, useInsertionEffect, useRef} from 'react'
// This should be used sparingly. It erases reactivity, i.e. when the inputs const noop = () => {}
// change, the function itself will remain the same. This means that if you
// use this at a higher level of your tree, and then some state you read in it /**
// changes, there is no mechanism for anything below in the tree to "react" * This should be used sparingly. It erases reactivity, i.e. when the inputs
// to this change (e.g. by knowing to call your function again). * change, the function itself will remain the same. This means that if you use
// * this at a higher level of your tree, and then some state you read in it
// Also, you should avoid calling the returned function during rendering * changes, there is no mechanism for anything below in the tree to "react" to
// since the values captured by it are going to lag behind. * this change (e.g. by knowing to call your function again).
export function useNonReactiveCallback<T extends Function>(fn: T): T { *
const ref = useRef(fn) * Also, you should avoid calling the returned function during rendering since
* the values captured by it are going to lag behind.
*
* For objects, see `useNonReactiveObject` instead.
*/
export function useNonReactiveCallback<T extends Function = () => void>(
fn?: T,
): T {
const ref = useRef<T>((fn ?? noop) as T)
useInsertionEffect(() => { useInsertionEffect(() => {
ref.current = fn ref.current = (fn ?? noop) as T
}, [fn]) }, [fn])
return useCallback( return useCallback(
(...args: any) => { (...args: any) => {
+20
View File
@@ -0,0 +1,20 @@
import {useInsertionEffect, useRef} from 'react'
/**
* This should be used sparingly. It erases reactivity, i.e. when the inputs
* change, the returned object itself will remain the same. This means that if
* you use this at a higher level of your tree, and then some state you read in
* it changes, there is no mechanism for anything below in the tree to "react"
* to this change (e.g. by knowing to call your function again).
*
* For callbacks, see `useNonReactiveCallback` instead.
*/
export function useNonReactiveObject<T extends Record<string, unknown>>(
o: T,
): React.RefObject<T> {
const ref = useRef(o)
useInsertionEffect(() => {
ref.current = o
}, [o])
return ref
}
+18 -5
View File
@@ -29,6 +29,7 @@ export type NotificationReason =
| 'reply' | 'reply'
| 'quote' | 'quote'
| 'chat-message' | 'chat-message'
| 'chat-reaction'
| 'starterpack-joined' | 'starterpack-joined'
| 'like-via-repost' | 'like-via-repost'
| 'repost-via-repost' | 'repost-via-repost'
@@ -44,7 +45,7 @@ export type NotificationReason =
export type NotificationPayload = export type NotificationPayload =
| undefined | undefined
| { | {
reason: Exclude<NotificationReason, 'chat-message'> reason: Exclude<NotificationReason, 'chat-message' | 'chat-reaction'>
uri: string uri: string
subject: string subject: string
recipientDid: string recipientDid: string
@@ -55,6 +56,12 @@ export type NotificationPayload =
messageId: string messageId: string
recipientDid: string recipientDid: string
} }
| {
reason: 'chat-reaction'
convoId: string
messageId: string
recipientDid: string
}
const DEFAULT_HANDLER_OPTIONS = { const DEFAULT_HANDLER_OPTIONS = {
shouldShowBanner: false, shouldShowBanner: false,
@@ -192,8 +199,11 @@ export function useNotificationsHandler() {
const handleNotification = (payload?: NotificationPayload) => { const handleNotification = (payload?: NotificationPayload) => {
if (!payload) return if (!payload) return
if (payload.reason === 'chat-message') { if (
logger.debug(`useNotificationsHandler: handling chat message`, { payload.reason === 'chat-message' ||
payload.reason === 'chat-reaction'
) {
logger.debug(`useNotificationsHandler: handling chat notification`, {
payload, payload,
}) })
@@ -270,7 +280,8 @@ export function useNotificationsHandler() {
logger.debug('useNotificationsHandler: incoming', {e, payload}) logger.debug('useNotificationsHandler: incoming', {e, payload})
if ( if (
payload.reason === 'chat-message' && (payload.reason === 'chat-message' ||
payload.reason === 'chat-reaction') &&
payload.recipientDid === currentAccount?.did payload.recipientDid === currentAccount?.did
) { ) {
const shouldAlert = payload.convoId !== currentConvoId const shouldAlert = payload.convoId !== currentConvoId
@@ -341,7 +352,8 @@ export function useNotificationsHandler() {
// Whenever there's a stored payload, that means we had to switch accounts before handling the notification. // Whenever there's a stored payload, that means we had to switch accounts before handling the notification.
// Whenever currentAccount changes, we should try to handle it again. // Whenever currentAccount changes, we should try to handle it again.
if ( if (
storedAccountSwitchPayload?.reason === 'chat-message' && (storedAccountSwitchPayload?.reason === 'chat-message' ||
storedAccountSwitchPayload?.reason === 'chat-reaction') &&
currentAccount?.did === storedAccountSwitchPayload.recipientDid currentAccount?.did === storedAccountSwitchPayload.recipientDid
) { ) {
handleNotification(storedAccountSwitchPayload) handleNotification(storedAccountSwitchPayload)
@@ -429,6 +441,7 @@ export function notificationToURL(payload: NotificationPayload): string | null {
return `/profile/${urip.host}` return `/profile/${urip.host}`
} }
case 'chat-message': case 'chat-message':
case 'chat-reaction':
// should be handled separately // should be handled separately
return null return null
case 'verified': case 'verified':
+71
View File
@@ -0,0 +1,71 @@
import {useEffect} from 'react'
import {IS_WEB_MOBILE_IOS} from '#/env'
const ZOOM_LOCKED_VIEWPORT =
'width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, viewport-fit=cover'
/**
* iOS Safari zooms in when a text input with `font-size < 16px` gains focus.
* To avoid that while still allowing users to pinch-zoom for accessibility, we
* only pin `maximum-scale=1` while a text input is focused and restore the
* original viewport on blur.
*
* Listeners run in the capture phase so we update the viewport before iOS
* commits to auto-zooming the input.
*/
export function useViewportZoomLock({enabled} = {enabled: true}) {
useEffect(() => {
if (!IS_WEB_MOBILE_IOS) return
if (!enabled) return
const meta = document.querySelector('meta[name="viewport"]')
if (!(meta instanceof HTMLMetaElement)) return
const originalContent = meta.content
const onFocus = (e: FocusEvent) => {
if (isTextInput(e.target)) {
meta.content = ZOOM_LOCKED_VIEWPORT
}
}
const onBlur = (e: FocusEvent) => {
if (isTextInput(e.target)) {
meta.content = originalContent
}
}
document.addEventListener('focus', onFocus, true)
document.addEventListener('blur', onBlur, true)
return () => {
document.removeEventListener('focus', onFocus, true)
document.removeEventListener('blur', onBlur, true)
meta.content = originalContent
}
}, [enabled])
}
const NON_TEXT_INPUT_TYPES = new Set([
'button',
'checkbox',
'color',
'file',
'hidden',
'image',
'radio',
'range',
'reset',
'submit',
])
function isTextInput(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
if (target instanceof HTMLTextAreaElement) return true
if (target instanceof HTMLInputElement) {
return !NON_TEXT_INPUT_TYPES.has(target.type)
}
return false
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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