Merge branch 'main' into app-2067
This commit is contained in:
@@ -109,23 +109,46 @@ jobs:
|
||||
run: |
|
||||
if [ -f "build.tar.gz" ]; then
|
||||
echo "Extracting build.tar.gz..."
|
||||
mkdir ios-build
|
||||
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
|
||||
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
|
||||
run: >
|
||||
SENTRY_ORG=blueskyweb
|
||||
SENTRY_PROJECT=app
|
||||
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
|
||||
id: get-build-info
|
||||
|
||||
@@ -239,10 +239,52 @@ jobs:
|
||||
yarn use-build-number-with-bump
|
||||
eas build -p ios
|
||||
--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
|
||||
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
|
||||
id: get-base-commit
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
**Tech Stack:**
|
||||
|
||||
- React 19.1
|
||||
- React Native 0.81 with Expo 54
|
||||
- TypeScript
|
||||
- React Navigation for routing
|
||||
- TypeScript 6
|
||||
- React Navigation 7 for routing
|
||||
- TanStack Query (React Query) for data fetching
|
||||
- Lingui for internationalization
|
||||
- Lingui 5 for internationalization
|
||||
- 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
|
||||
|
||||
```bash
|
||||
@@ -162,6 +166,10 @@ its own looks more like a single component file.
|
||||
|
||||
### 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
|
||||
within the directory that explains the purpose of the feature, how it works, and
|
||||
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
|
||||
|
||||
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
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
@@ -190,9 +206,7 @@ function MyComponent() {
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
Hello
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>Hello</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -200,20 +214,23 @@ function MyComponent() {
|
||||
|
||||
### Key Concepts
|
||||
|
||||
**Static Atoms** - Theme-independent styles imported from `atoms`:
|
||||
**Static Atoms** – Theme-independent styles imported from `atoms`:
|
||||
|
||||
```tsx
|
||||
import {atoms as a} from '#/alf'
|
||||
// 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
|
||||
const t = useTheme()
|
||||
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
|
||||
// t.palette.primary_500, t.palette.negative_400, etc.
|
||||
```
|
||||
|
||||
**Platform Utilities** - For platform-specific styles:
|
||||
**Platform Utilities** – For platform-specific styles:
|
||||
|
||||
```tsx
|
||||
import {web, native, ios, android, platform} from '#/alf'
|
||||
|
||||
@@ -225,7 +242,8 @@ const styles = [
|
||||
]
|
||||
```
|
||||
|
||||
**Breakpoints** - Responsive design:
|
||||
**Breakpoints** – Responsive design:
|
||||
|
||||
```tsx
|
||||
import {useBreakpoints} from '#/alf'
|
||||
|
||||
@@ -245,6 +263,38 @@ if (gtMobile) {
|
||||
|
||||
## 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
|
||||
|
||||
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}>
|
||||
{/* Typically the inner part is in its own component */}
|
||||
<Dialog.Handle /> {/* Native-only drag handle */}
|
||||
<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>
|
||||
<DialogInner />
|
||||
</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
|
||||
@@ -345,6 +401,7 @@ import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||
```
|
||||
|
||||
**Button Props:**
|
||||
|
||||
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
|
||||
- `size`: `'tiny'` | `'small'` | `'large'`
|
||||
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
|
||||
@@ -384,39 +441,66 @@ import * as TextField from '#/components/forms/TextField'
|
||||
|
||||
## 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
|
||||
import {msg, plural} from '@lingui/core/macro'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
function MyComponent() {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
// Simple strings - use msg() with _() function
|
||||
const title = _(msg`Settings`)
|
||||
const errorMessage = _(msg`Something went wrong`)
|
||||
// Simple strings - use the l macro
|
||||
const title = l`Settings`
|
||||
const errorMessage = l({
|
||||
message: 'Something went wrong',
|
||||
comment: 'Generic error message for unknown/unhandled errors.',
|
||||
context: 'Toast',
|
||||
})
|
||||
|
||||
// Strings with variables
|
||||
const greeting = _(msg`Hello, ${name}!`)
|
||||
const greeting = l`Hello, ${name}!`
|
||||
|
||||
// Pluralization
|
||||
const countLabel = _(plural(count, {
|
||||
const countLabel = plural(count, {
|
||||
one: '# item',
|
||||
other: '# items',
|
||||
}))
|
||||
})
|
||||
|
||||
// JSX content - use Trans component
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
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:**
|
||||
|
||||
```bash
|
||||
# DO NOT run these commands - extraction and compilation are handled by a nightly CI job
|
||||
yarn intl:extract # Extract new strings to locale files
|
||||
@@ -473,7 +557,7 @@ export function useProfileMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data) => {
|
||||
mutationFn: async data => {
|
||||
// Update logic
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
@@ -481,7 +565,7 @@ export function useProfileMutation() {
|
||||
queryKey: createProfileQueryKey({did: variables.did}),
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: error => {
|
||||
if (isNetworkError(error)) {
|
||||
// don't log, but inform user
|
||||
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
|
||||
@@ -490,7 +574,7 @@ export function useProfileMutation() {
|
||||
// Log unexpected errors to Sentry
|
||||
logger.error('Error updating profile', {safeMessage: error})
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -505,21 +589,25 @@ export function useProfileCacheMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return (data: Partial<Profile>) => {
|
||||
queryClient.setQueryData(createProfileQueryKey({did: data.did}), oldData => {
|
||||
if (!oldData) return oldData
|
||||
return {...oldData, ...data}
|
||||
})
|
||||
queryClient.setQueryData(
|
||||
createProfileQueryKey({did: data.did}),
|
||||
oldData => {
|
||||
if (!oldData) return oldData
|
||||
return {...oldData, ...data}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Stale Time Constants** (from `src/state/queries/index.ts`):
|
||||
|
||||
```tsx
|
||||
STALE.SECONDS.FIFTEEN // 15 seconds
|
||||
STALE.MINUTES.ONE // 1 minute
|
||||
STALE.MINUTES.FIVE // 5 minutes
|
||||
STALE.HOURS.ONE // 1 hour
|
||||
STALE.INFINITY // Never stale
|
||||
STALE.SECONDS.FIFTEEN // 15 seconds
|
||||
STALE.MINUTES.ONE // 1 minute
|
||||
STALE.MINUTES.FIVE // 5 minutes
|
||||
STALE.HOURS.ONE // 1 hour
|
||||
STALE.INFINITY // Never stale
|
||||
```
|
||||
|
||||
**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 setAutoplayDisabled = useSetAutoplayDisabled()
|
||||
|
||||
return (
|
||||
<Toggle
|
||||
value={autoplayDisabled}
|
||||
onValueChange={setAutoplayDisabled}
|
||||
/>
|
||||
)
|
||||
return <Toggle value={autoplayDisabled} onValueChange={setAutoplayDisabled} />
|
||||
}
|
||||
```
|
||||
|
||||
@@ -604,13 +687,9 @@ import {type CommonNavigatorParams} from '#/lib/routes/types'
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
|
||||
|
||||
export function ProfileScreen({route, navigation}: Props) {
|
||||
const {name} = route.params // Type-safe params
|
||||
const {name} = route.params // Type-safe params
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
{/* Screen content */}
|
||||
</Layout.Screen>
|
||||
)
|
||||
return <Layout.Screen>{/* Screen content */}</Layout.Screen>
|
||||
}
|
||||
|
||||
// Programmatic navigation
|
||||
@@ -637,8 +716,9 @@ Component.android.tsx # Android-only
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
@@ -653,6 +733,7 @@ const storage = IS_NATIVE
|
||||
```
|
||||
|
||||
Platform detection (for runtime logic, not imports):
|
||||
|
||||
```tsx
|
||||
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
|
||||
const onConfirm = () => {
|
||||
control.close()
|
||||
navigation.navigate('Home') // May race with dialog animation
|
||||
navigation.navigate('Home') // May race with dialog animation
|
||||
}
|
||||
|
||||
// WRONG - same problem
|
||||
const onConfirm = () => {
|
||||
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
|
||||
@@ -720,12 +801,13 @@ const onConfirm = () => {
|
||||
```
|
||||
|
||||
This applies to:
|
||||
|
||||
- Navigation (`navigation.navigate()`, `navigation.push()`)
|
||||
- Opening other dialogs or menus
|
||||
- State updates that affect UI (`setState`, `queryClient.invalidateQueries`)
|
||||
- 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
|
||||
|
||||
@@ -748,10 +830,11 @@ Prefer `defaultValue` over `value` for TextInput on the old architecture:
|
||||
### Platform-Specific Behavior
|
||||
|
||||
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)
|
||||
- `Menu.Divider` - Only renders on web
|
||||
- `Menu.ContainerItem` - Only works on native
|
||||
|
||||
- `Dialog.Handle` – Only renders on native (drag handle for bottom sheet)
|
||||
- `Dialog.Close` – Only renders on web (X button)
|
||||
- `Menu.Divider` – Only renders on web
|
||||
- `Menu.ContainerItem` – Only works on native
|
||||
|
||||
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:
|
||||
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
@@ -793,14 +877,14 @@ Only use `useMemo`/`useCallback` when you have a specific reason, such as:
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
| Purpose | Location |
|
||||
|---------|----------|
|
||||
| Theme definitions | `src/alf/themes.ts` |
|
||||
| Design tokens | `src/alf/tokens.ts` |
|
||||
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
|
||||
| Navigation config | `src/Navigation.tsx` |
|
||||
| Route definitions | `src/routes.ts` |
|
||||
| Route types | `src/lib/routes/types.ts` |
|
||||
| Query hooks | `src/state/queries/*.ts` |
|
||||
| Session state | `src/state/session/index.tsx` |
|
||||
| i18n setup | `src/locale/i18n.ts` |
|
||||
| Purpose | Location |
|
||||
| ----------------- | -------------------------------------------- |
|
||||
| Theme definitions | `src/alf/themes.ts` |
|
||||
| Design tokens | `src/alf/tokens.ts` |
|
||||
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
|
||||
| Navigation config | `src/Navigation.tsx` |
|
||||
| Route definitions | `src/routes.ts` |
|
||||
| Route types | `src/lib/routes/types.ts` |
|
||||
| Query hooks | `src/state/queries/*.ts` |
|
||||
| Session state | `src/state/session/index.tsx` |
|
||||
| i18n setup | `src/locale/i18n.ts` |
|
||||
|
||||
@@ -66,6 +66,7 @@ module.exports = function (_config) {
|
||||
infoPlist: {
|
||||
CADisableMinimumFrameDurationOnPhone: true,
|
||||
UIBackgroundModes: ['remote-notification'],
|
||||
NSUserActivityTypes: ['INSendMessageIntent'],
|
||||
NSCameraUsageDescription:
|
||||
'Used for profile pictures, posts, and other kinds of content.',
|
||||
NSMicrophoneUsageDescription:
|
||||
@@ -123,6 +124,7 @@ module.exports = function (_config) {
|
||||
'com.apple.developer.kernel.increased-memory-limit': true,
|
||||
'com.apple.developer.kernel.extended-virtual-addressing': true,
|
||||
'com.apple.security.application-groups': 'group.app.bsky',
|
||||
'com.apple.developer.usernotifications.communication': true,
|
||||
// 'com.apple.developer.device-information.user-assigned-device-name': true,
|
||||
},
|
||||
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 |
@@ -2,7 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<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">
|
||||
<!--
|
||||
Preconnect to essential domains
|
||||
|
||||
@@ -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
@@ -229,7 +229,7 @@ export default defineConfig(
|
||||
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too
|
||||
* nit-picky
|
||||
*/
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'@typescript-eslint/no-unsafe-function-type': 'off',
|
||||
|
||||
+7
-3
@@ -61,9 +61,13 @@ jest.mock('expo-media-library', () => ({
|
||||
usePermissions: jest.fn(() => [true]),
|
||||
}))
|
||||
|
||||
jest.mock('lande', () => ({
|
||||
__esModule: true, // this property makes it work
|
||||
default: jest.fn().mockReturnValue([['eng']]),
|
||||
jest.mock('@bsky.app/expo-guess-language', () => ({
|
||||
guessLanguageSync: jest
|
||||
.fn()
|
||||
.mockReturnValue([{language: 'en', confidence: 1}]),
|
||||
guessLanguageAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{language: 'en', confidence: 1}]),
|
||||
}))
|
||||
|
||||
jest.mock('sentry-expo', () => ({
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
<string>com.apple.usernotifications.service</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>IntentsSupported</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>MainAppScheme</key>
|
||||
<string>bluesky</string>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import UserNotifications
|
||||
import UIKit
|
||||
import Intents
|
||||
|
||||
let APP_GROUP = "group.app.bsky"
|
||||
typealias ContentHandler = (UNNotificationContent) -> Void
|
||||
@@ -40,17 +41,18 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
|
||||
self.bestAttempt = bestAttempt
|
||||
if reason == "chat-message" {
|
||||
|
||||
if reason == "chat-message" || reason == "chat-reaction" {
|
||||
mutateWithChatMessage(bestAttempt)
|
||||
let finalContent = createCommunicationNotification(
|
||||
from: bestAttempt,
|
||||
userInfo: request.content.userInfo
|
||||
)
|
||||
contentHandler(finalContent)
|
||||
} else {
|
||||
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() {
|
||||
@@ -61,6 +63,81 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
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
|
||||
|
||||
func mutateWithBadge(_ content: UNMutableNotificationContent) {
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ class BackgroundNotificationHandler(
|
||||
return
|
||||
}
|
||||
|
||||
if (remoteMessage.data["reason"] == "chat-message") {
|
||||
if (remoteMessage.data["reason"] == "chat-message" || remoteMessage.data["reason"] == "chat-reaction") {
|
||||
mutateWithChatMessage(remoteMessage)
|
||||
} else {
|
||||
mutateWithOtherReason(remoteMessage)
|
||||
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.121.0",
|
||||
"version": "1.122.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -51,7 +51,7 @@
|
||||
"lint": "eslint --cache --quiet src",
|
||||
"lint-native": "swiftlint ./modules && ktlint ./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: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",
|
||||
@@ -81,10 +81,12 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.19.9",
|
||||
"@atproto/api": "^0.19.11",
|
||||
"@atproto/syntax": "0.5.2",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@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-scroll-edge-effect": "^0.1.4",
|
||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||
@@ -261,6 +263,7 @@
|
||||
"@types/psl": "^1.1.1",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260428.1",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"babel-plugin-react-compiler": "^19.1.0-rc.3",
|
||||
|
||||
+2
-3
@@ -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 {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}
|
||||
*/
|
||||
if (IS_WEB) {
|
||||
// @ts-expect-error - web supports 'unicode' as a valid value for fontVariant
|
||||
style.fontVariant = (style.fontVariant || []).concat(
|
||||
'no-contextual',
|
||||
'unicode',
|
||||
'unicode' as FontVariant, // web supports 'unicode' as a valid value for fontVariant
|
||||
)
|
||||
} else {
|
||||
style.fontVariant = (style.fontVariant || []).concat('no-contextual')
|
||||
|
||||
@@ -12,8 +12,11 @@ export enum Features {
|
||||
ImageUploadsHighResolution = 'image_uploads:high_resolution',
|
||||
ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled',
|
||||
GroupChatsEnable = 'group_chats:enable',
|
||||
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
|
||||
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||
KlipyGifProviderEnable = 'klipy_gif_provider:enable',
|
||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||
|
||||
AATest = 'aa-test',
|
||||
}
|
||||
|
||||
@@ -487,6 +487,7 @@ export type Events = {
|
||||
| 'SeeMoreSuggestedUsers'
|
||||
| 'ProgressGuide'
|
||||
location: 'Card' | 'Profile' | 'FollowAll'
|
||||
recSource?: 'Search'
|
||||
recId?: number | string
|
||||
position: number
|
||||
suggestedDid: string
|
||||
@@ -514,6 +515,7 @@ export type Events = {
|
||||
| 'Onboarding'
|
||||
| 'SeeMoreSuggestedUsers'
|
||||
| 'ProgressGuide'
|
||||
recSource?: 'Search'
|
||||
recId?: number | string
|
||||
position: number
|
||||
suggestedDid: string
|
||||
@@ -554,7 +556,11 @@ export type Events = {
|
||||
| 'FindContacts'
|
||||
}
|
||||
'chat:create': {
|
||||
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
||||
logContext:
|
||||
| 'ProfileHeader'
|
||||
| 'NewChatDialog'
|
||||
| 'SendViaChatDialog'
|
||||
| 'ConvoSettings'
|
||||
}
|
||||
'chat:open': {
|
||||
logContext:
|
||||
@@ -562,6 +568,7 @@ export type Events = {
|
||||
| 'NewChatDialog'
|
||||
| 'ChatsList'
|
||||
| 'SendViaChatDialog'
|
||||
| 'ConvoSettings'
|
||||
}
|
||||
'groupchat:create': {
|
||||
logContext: 'NewChatDialog'
|
||||
@@ -798,6 +805,100 @@ export type Events = {
|
||||
*/
|
||||
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': {
|
||||
uri: string
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback} from 'react'
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
||||
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
|
||||
},
|
||||
[q, showSearchFallback, moderationOpts],
|
||||
[q, moderationOpts],
|
||||
),
|
||||
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 {
|
||||
query: q,
|
||||
items: query.data || [],
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import {useCallback, useEffect} from 'react'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {useEffect} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
type SharedValue,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
@@ -16,36 +15,32 @@ import {atoms as a, useTheme} from '#/alf'
|
||||
import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
type Layout = {
|
||||
size: number
|
||||
x: number
|
||||
y: number
|
||||
zIndex?: number
|
||||
border?: boolean
|
||||
}
|
||||
|
||||
type Props = {
|
||||
animate?: boolean
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
size?: 'small' | 'medium' | 'large' | number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export function AvatarBubbles({
|
||||
animate = false,
|
||||
profiles: allProfiles,
|
||||
size = 'large',
|
||||
size = 120,
|
||||
}: Props) {
|
||||
const {currentAccount} = useSession()
|
||||
const profiles = allProfiles.filter(p => p.did !== currentAccount?.did)
|
||||
const containerSize =
|
||||
typeof size === 'number'
|
||||
? size
|
||||
: size === 'small'
|
||||
? 40
|
||||
: 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 profiles =
|
||||
allProfiles.length > 2
|
||||
? allProfiles.filter(p => p.did !== currentAccount?.did)
|
||||
: allProfiles
|
||||
const scale = size / 120
|
||||
const marginOffset = size < 120 ? -2 : 0
|
||||
|
||||
const initialValue = animate ? 0 : 1
|
||||
const p0 = useSharedValue(initialValue)
|
||||
@@ -53,130 +48,50 @@ export function AvatarBubbles({
|
||||
const p2 = 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(() => {
|
||||
if (!animate) return
|
||||
playScaleAnimation()
|
||||
}, [animate, playScaleAnimation])
|
||||
const animateBubble = (p: SharedValue<number>, i: number) => {
|
||||
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 = (
|
||||
<>
|
||||
<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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
const scales = [p0, p1, p2, p3]
|
||||
const layouts = getLayouts(profiles.length)
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
a.p_2xs,
|
||||
{
|
||||
height: containerSize,
|
||||
width: containerSize,
|
||||
},
|
||||
]}>
|
||||
<Animated.View style={[a.p_2xs, {height: size, width: size}]}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
marginTop: marginOffset,
|
||||
marginLeft: marginOffset,
|
||||
transform: [{scale}],
|
||||
transformOrigin: 'top left',
|
||||
},
|
||||
]}>
|
||||
{avatars}
|
||||
style={{
|
||||
marginTop: marginOffset,
|
||||
marginLeft: marginOffset,
|
||||
transform: [{scale}],
|
||||
transformOrigin: 'top left',
|
||||
}}>
|
||||
{layouts.map((layout, i) => (
|
||||
<AvatarBubble
|
||||
key={i}
|
||||
profile={profiles[i]}
|
||||
scale={scales[i]}
|
||||
size={layout.size}
|
||||
x={layout.x}
|
||||
y={layout.y}
|
||||
zIndex={layout.zIndex}
|
||||
includeProfileBorder={layout.border}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
)
|
||||
@@ -186,27 +101,23 @@ function AvatarBubble({
|
||||
profile,
|
||||
scale,
|
||||
size,
|
||||
style,
|
||||
x,
|
||||
y,
|
||||
zIndex,
|
||||
includeProfileBorder,
|
||||
}: {
|
||||
profile?: bsky.profile.AnyProfileView
|
||||
scale: SharedValue<number>
|
||||
size: number
|
||||
style?: StyleProp<ViewStyle>
|
||||
x: number
|
||||
y: number
|
||||
zIndex?: number
|
||||
includeProfileBorder?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{translateX: x},
|
||||
{translateY: y},
|
||||
{scale: interpolate(scale.get(), [0, 1], [0, 1])},
|
||||
],
|
||||
transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}],
|
||||
}))
|
||||
|
||||
return (
|
||||
@@ -219,11 +130,17 @@ function AvatarBubble({
|
||||
borderColor: t.atoms.text_inverted.color,
|
||||
borderWidth: 2,
|
||||
},
|
||||
style,
|
||||
zIndex != null && {zIndex},
|
||||
animatedStyle,
|
||||
]}>
|
||||
{profile ? (
|
||||
<Avatar profile={profile} size={size} />
|
||||
<UserAvatar
|
||||
avatar={profile.avatar}
|
||||
size={size}
|
||||
type="user"
|
||||
hideLiveBadge
|
||||
noBorder
|
||||
/>
|
||||
) : (
|
||||
<AvatarPlaceholder size={size} />
|
||||
)}
|
||||
@@ -231,25 +148,7 @@ function AvatarBubble({
|
||||
)
|
||||
}
|
||||
|
||||
function Avatar({
|
||||
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}) {
|
||||
function AvatarPlaceholder({size}: {size: number}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
@@ -259,10 +158,7 @@ function AvatarPlaceholder({size = 76}: {size?: number}) {
|
||||
a.justify_center,
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_200,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
{width: size, height: size},
|
||||
]}>
|
||||
<PersonIcon
|
||||
width={size * 0.5}
|
||||
@@ -272,3 +168,25 @@ function AvatarPlaceholder({size = 76}: {size?: number}) {
|
||||
</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},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@ export type ButtonState = {
|
||||
focused: boolean
|
||||
pressed: boolean
|
||||
disabled: boolean
|
||||
/**
|
||||
* Alias for hovered || focused || pressed
|
||||
*/
|
||||
interacting: boolean
|
||||
}
|
||||
|
||||
export type ButtonContext = VariantProps & ButtonState
|
||||
@@ -120,6 +124,7 @@ const Context = createContext<VariantProps & ButtonState>({
|
||||
focused: false,
|
||||
pressed: false,
|
||||
disabled: false,
|
||||
interacting: false,
|
||||
})
|
||||
Context.displayName = 'ButtonContext'
|
||||
|
||||
@@ -536,6 +541,7 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
const context = useMemo<ButtonContext>(
|
||||
() => ({
|
||||
...state,
|
||||
interacting: state.hovered || state.focused || state.pressed,
|
||||
variant,
|
||||
color,
|
||||
size,
|
||||
|
||||
@@ -54,7 +54,7 @@ import {HITSLOP_10} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {logger} from '#/logger'
|
||||
import {atoms as a, platform, tokens, useTheme} from '#/alf'
|
||||
import {atoms as a, flatten, platform, tokens, useTheme} from '#/alf'
|
||||
import {
|
||||
Context,
|
||||
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(() => {
|
||||
if (!measurement) return
|
||||
@@ -655,7 +656,8 @@ export function Outer({
|
||||
[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
|
||||
|
||||
@@ -763,7 +765,7 @@ export function Item({
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
const id = useId()
|
||||
const {align} = useContextMenuMenuContext()
|
||||
const {align, xOffset: menuXOffset} = useContextMenuMenuContext()
|
||||
|
||||
const {close, measurement, registerHoverable} = context
|
||||
|
||||
@@ -779,8 +781,8 @@ export function Item({
|
||||
const xOffset = position
|
||||
? position.x
|
||||
: align === 'left'
|
||||
? measurement.x
|
||||
: measurement.x + measurement.width - layout.width
|
||||
? measurement.x + menuXOffset
|
||||
: measurement.x + measurement.width - layout.width - menuXOffset
|
||||
|
||||
registerHoverable(
|
||||
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(
|
||||
|
||||
@@ -65,6 +65,7 @@ export type ContextType = {
|
||||
|
||||
export type MenuContextType = {
|
||||
align: 'left' | 'right'
|
||||
xOffset: number
|
||||
}
|
||||
|
||||
export type ItemContextType = {
|
||||
|
||||
@@ -167,6 +167,7 @@ export function SuggestedFollowsHome() {
|
||||
|
||||
return (
|
||||
<ProfileGrid
|
||||
recId={data?.recId}
|
||||
isSuggestionsLoading={isLoading}
|
||||
profiles={filteredProfiles}
|
||||
totalProfileCount={allProfiles.length}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import {forwardRef, memo, useContext, useMemo} from 'react'
|
||||
import {StyleSheet, View, type ViewProps, type ViewStyle} from 'react-native'
|
||||
import {type StyleProp} from 'react-native'
|
||||
import {
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
type ViewProps,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {
|
||||
KeyboardAwareScrollView,
|
||||
type KeyboardAwareScrollViewProps,
|
||||
@@ -11,6 +16,7 @@ import Animated, {
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
|
||||
import {useEnableMinimalShellModeForScreen} from '#/state/shell'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {
|
||||
atoms as a,
|
||||
@@ -30,6 +36,7 @@ export * as Header from '#/components/Layout/Header'
|
||||
export type ScreenProps = React.ComponentProps<typeof View> & {
|
||||
style?: StyleProp<ViewStyle>
|
||||
noInsetTop?: boolean
|
||||
minimalShell?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,9 +45,13 @@ export type ScreenProps = React.ComponentProps<typeof View> & {
|
||||
export const Screen = memo(function Screen({
|
||||
style,
|
||||
noInsetTop,
|
||||
minimalShell = false,
|
||||
...props
|
||||
}: ScreenProps) {
|
||||
const {top} = useSafeAreaInsets()
|
||||
|
||||
useEnableMinimalShellModeForScreen({enabled: minimalShell})
|
||||
|
||||
return (
|
||||
<>
|
||||
{IS_WEB && <WebCenterBorders />}
|
||||
|
||||
@@ -201,7 +201,7 @@ export function AvatarPlaceholder({size = 40}: {size?: number}) {
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
@@ -348,7 +348,7 @@ export function NameAndHandlePlaceholder() {
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
width: '60%',
|
||||
height: 14,
|
||||
@@ -359,7 +359,7 @@ export function NameAndHandlePlaceholder() {
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
width: '40%',
|
||||
height: 10,
|
||||
@@ -377,7 +377,7 @@ export function NamePlaceholder({style}: ViewStyleProp) {
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
width: '60%',
|
||||
height: 14,
|
||||
@@ -439,7 +439,7 @@ export function DescriptionPlaceholder({
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
a.w_full,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.bg_contrast_50,
|
||||
{height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
|
||||
]}
|
||||
/>
|
||||
@@ -600,7 +600,7 @@ export function FollowButtonPlaceholder({style}: ViewStyleProp) {
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.bg_contrast_50,
|
||||
a.w_full,
|
||||
{
|
||||
height: 33,
|
||||
|
||||
@@ -249,6 +249,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
moderationOpts={moderationOpts!}
|
||||
noBorder={index === 0}
|
||||
position={index}
|
||||
recSource={hasSearchText ? 'Search' : undefined}
|
||||
recId={recIdForLogging}
|
||||
isGuide={isGuide}
|
||||
/>
|
||||
@@ -264,7 +265,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
return null
|
||||
}
|
||||
},
|
||||
[moderationOpts, recIdForLogging, isGuide],
|
||||
[moderationOpts, hasSearchText, recIdForLogging, isGuide],
|
||||
)
|
||||
|
||||
// Track seen profiles
|
||||
@@ -286,6 +287,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
)
|
||||
ax.metric('suggestedUser:seen', {
|
||||
logContext: isGuide ? 'ProgressGuide' : 'SeeMoreSuggestedUsers',
|
||||
recSource: hasSearchText ? 'Search' : undefined,
|
||||
recId: recIdForLogging,
|
||||
position: position !== -1 ? position : 0,
|
||||
suggestedDid: item.profile.did,
|
||||
@@ -548,6 +550,7 @@ let FollowProfileCard = ({
|
||||
moderationOpts,
|
||||
noBorder,
|
||||
position,
|
||||
recSource,
|
||||
recId,
|
||||
isGuide,
|
||||
}: {
|
||||
@@ -555,6 +558,7 @@ let FollowProfileCard = ({
|
||||
moderationOpts: ModerationOpts
|
||||
noBorder?: boolean
|
||||
position: number
|
||||
recSource?: 'Search'
|
||||
recId?: string
|
||||
isGuide: boolean
|
||||
}): React.ReactNode => {
|
||||
@@ -564,6 +568,7 @@ let FollowProfileCard = ({
|
||||
moderationOpts={moderationOpts}
|
||||
noBorder={noBorder}
|
||||
position={position}
|
||||
recSource={recSource}
|
||||
recId={recId}
|
||||
isGuide={isGuide}
|
||||
/>
|
||||
@@ -577,6 +582,7 @@ function FollowProfileCardInner({
|
||||
onFollow,
|
||||
noBorder,
|
||||
position,
|
||||
recSource,
|
||||
recId,
|
||||
isGuide,
|
||||
}: {
|
||||
@@ -585,6 +591,7 @@ function FollowProfileCardInner({
|
||||
onFollow?: () => void
|
||||
noBorder?: boolean
|
||||
position: number
|
||||
recSource?: 'Search'
|
||||
recId?: string
|
||||
isGuide: boolean
|
||||
}) {
|
||||
@@ -625,6 +632,7 @@ function FollowProfileCardInner({
|
||||
? 'ProgressGuide'
|
||||
: 'SeeMoreSuggestedUsers',
|
||||
location: 'Card',
|
||||
recSource,
|
||||
recId,
|
||||
position,
|
||||
suggestedDid: profile.did,
|
||||
|
||||
@@ -3,7 +3,7 @@ import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
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 {
|
||||
Button,
|
||||
type ButtonColor,
|
||||
@@ -34,6 +34,8 @@ export function Outer({
|
||||
control,
|
||||
testID,
|
||||
nativeOptions,
|
||||
webOptions,
|
||||
onClose,
|
||||
}: React.PropsWithChildren<{
|
||||
control: Dialog.DialogControlProps
|
||||
testID?: string
|
||||
@@ -41,6 +43,13 @@ export function Outer({
|
||||
* Native-specific options for the prompt. Extends `BottomSheetViewProps`
|
||||
*/
|
||||
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
|
||||
/**
|
||||
* Web-specific options for the prompt
|
||||
*/
|
||||
webOptions?: {
|
||||
onBackgroundPress?: (e: GestureResponderEvent) => void
|
||||
}
|
||||
onClose?: () => void
|
||||
}>) {
|
||||
const titleId = useId()
|
||||
const descriptionId = useId()
|
||||
@@ -54,7 +63,8 @@ export function Outer({
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID={testID}
|
||||
webOptions={{alignCenter: true}}
|
||||
onClose={onClose}
|
||||
webOptions={{alignCenter: true, ...webOptions}}
|
||||
nativeOptions={{preventExpansion: true, ...nativeOptions}}>
|
||||
<Dialog.Handle />
|
||||
<Context.Provider value={context}>
|
||||
@@ -72,7 +82,7 @@ export function Outer({
|
||||
export function TitleText({
|
||||
children,
|
||||
style,
|
||||
}: React.PropsWithChildren<ViewStyleProp>) {
|
||||
}: React.PropsWithChildren<TextStyleProp>) {
|
||||
const {titleId} = useContext(Context)
|
||||
return (
|
||||
<Text
|
||||
@@ -93,14 +103,21 @@ export function TitleText({
|
||||
export function DescriptionText({
|
||||
children,
|
||||
selectable,
|
||||
}: React.PropsWithChildren<{selectable?: boolean}>) {
|
||||
style,
|
||||
}: React.PropsWithChildren<{selectable?: boolean} & TextStyleProp>) {
|
||||
const t = useTheme()
|
||||
const {descriptionId} = useContext(Context)
|
||||
return (
|
||||
<Text
|
||||
nativeID={descriptionId}
|
||||
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}
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -512,7 +512,7 @@ function ExistingChatCard({
|
||||
]}>
|
||||
<ProfileCard.Header>
|
||||
{convo.kind === 'group' ? (
|
||||
<AvatarBubbles profiles={convo.members} size="small" />
|
||||
<AvatarBubbles profiles={convo.members} size={40} />
|
||||
) : (
|
||||
<ProfileCard.Avatar
|
||||
profile={convo.primaryMember}
|
||||
|
||||
@@ -4,23 +4,29 @@ import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function ActionsWrapper({
|
||||
message,
|
||||
isFromSelf,
|
||||
senderProfile,
|
||||
children,
|
||||
onTap,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
hasReactions?: boolean
|
||||
isFromSelf: boolean
|
||||
senderProfile?: bsky.profile.AnyProfileView
|
||||
children: React.ReactNode
|
||||
onTap?: () => void
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<MessageContextMenu message={message} onTap={onTap}>
|
||||
<MessageContextMenu
|
||||
message={message}
|
||||
senderProfile={senderProfile}
|
||||
onTap={onTap}>
|
||||
{trigger =>
|
||||
// will always be true, since this file is platform split
|
||||
trigger.IS_NATIVE && (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
|
||||
import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {EmojiReactionPicker} from './EmojiReactionPicker'
|
||||
import {hasReachedReactionLimit} from './util'
|
||||
|
||||
@@ -17,12 +18,14 @@ export function ActionsWrapper({
|
||||
message,
|
||||
hasReactions,
|
||||
isFromSelf,
|
||||
senderProfile,
|
||||
children,
|
||||
onTap,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
hasReactions?: boolean
|
||||
isFromSelf: boolean
|
||||
senderProfile?: bsky.profile.AnyProfileView
|
||||
children: React.ReactNode
|
||||
onTap?: () => void
|
||||
}) {
|
||||
@@ -114,7 +117,7 @@ export function ActionsWrapper({
|
||||
)
|
||||
}}
|
||||
</EmojiReactionPicker>
|
||||
<MessageContextMenu message={message}>
|
||||
<MessageContextMenu message={message} senderProfile={senderProfile}>
|
||||
{({props, state, IS_NATIVE, control}) => {
|
||||
// always false, file is platform split
|
||||
if (IS_NATIVE) return null
|
||||
|
||||
@@ -11,16 +11,18 @@ import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||
import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members'
|
||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
import {type ListMethods} from '#/view/com/util/List'
|
||||
import {android, atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
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 {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
@@ -54,12 +56,12 @@ type PlaceholderItem = {
|
||||
key: string
|
||||
}
|
||||
|
||||
type ErrorItem = {
|
||||
type: 'error'
|
||||
type LoadingItem = {
|
||||
type: 'loading'
|
||||
key: string
|
||||
}
|
||||
|
||||
type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | ErrorItem
|
||||
type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | LoadingItem
|
||||
|
||||
export type State = {
|
||||
groupChatDids: string[]
|
||||
@@ -98,30 +100,46 @@ function reducer(state: State, action: Action): State {
|
||||
}
|
||||
|
||||
export function AddMembersFlow({
|
||||
convo,
|
||||
title,
|
||||
onAddMembers,
|
||||
}: {
|
||||
convo: Extract<ConvoWithDetails, {kind: 'group'}>
|
||||
title: string
|
||||
onAddMembers: (dids: string[]) => void
|
||||
onAddMembers: (
|
||||
dids: string[],
|
||||
profiles: bsky.profile.AnyProfileView[],
|
||||
) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
const [headerHeight, setHeaderHeight] = 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 listRef = useRef<ListMethods>(null)
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
|
||||
const {
|
||||
data: results,
|
||||
data: autocompleteResults,
|
||||
isError,
|
||||
isFetching,
|
||||
isFetching: isAutocompleteFetching,
|
||||
} = useActorAutocompleteQuery(searchText, true, 12)
|
||||
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, {
|
||||
groupChatDids: [],
|
||||
@@ -142,8 +160,13 @@ export function AddMembersFlow({
|
||||
[groupChatDids, groupChatProfiles],
|
||||
)
|
||||
|
||||
const items = useMemo(() => {
|
||||
let _items: Item[] = []
|
||||
const items = useMemo<Item[]>(() => {
|
||||
if (isMemberListPending) {
|
||||
// Still fetching chat member DIDs for filtering, so force the loading state.
|
||||
return []
|
||||
}
|
||||
|
||||
const _items: Item[] = []
|
||||
|
||||
if (isError) {
|
||||
_items.push({
|
||||
@@ -152,9 +175,13 @@ export function AddMembersFlow({
|
||||
message: l`We’re having network issues, try again`,
|
||||
})
|
||||
} else if (searchText.length) {
|
||||
if (results?.length) {
|
||||
for (const profile of results) {
|
||||
if (profile.did === currentAccount?.did) continue
|
||||
if (autocompleteResults?.length) {
|
||||
for (const profile of autocompleteResults) {
|
||||
if (
|
||||
profile.did === currentAccount?.did ||
|
||||
memberDidSet.has(profile.did)
|
||||
)
|
||||
continue
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
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
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const placeholders: Item[] = Array(10)
|
||||
.fill(0)
|
||||
.map((__, i) => ({
|
||||
type: 'placeholder',
|
||||
key: i + '',
|
||||
}))
|
||||
|
||||
if (follows) {
|
||||
for (const page of follows.pages) {
|
||||
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
|
||||
})
|
||||
} 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
|
||||
}, [isError, searchText, l, results, currentAccount?.did, follows])
|
||||
if (searchText && isAutocompleteFetching && _items.length > 0) {
|
||||
// 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) {
|
||||
items.push({type: 'empty', key: 'empty', message: l`No results`})
|
||||
}
|
||||
return _items
|
||||
}, [
|
||||
autocompleteResults,
|
||||
currentAccount?.did,
|
||||
follows,
|
||||
isAutocompleteFetching,
|
||||
isError,
|
||||
isMemberListPending,
|
||||
l,
|
||||
memberDidSet,
|
||||
searchText,
|
||||
])
|
||||
|
||||
const handlePressBack = useCallback(() => {
|
||||
control.close()
|
||||
}, [control])
|
||||
|
||||
const handlePressAdd = useCallback(() => {
|
||||
onAddMembers(groupChatDids)
|
||||
}, [groupChatDids, onAddMembers])
|
||||
onAddMembers(groupChatDids, groupChatProfiles)
|
||||
}, [groupChatDids, groupChatProfiles, onAddMembers])
|
||||
|
||||
const renderItems = useCallback(
|
||||
({item}: {item: Item}) => {
|
||||
@@ -234,6 +275,13 @@ export function AddMembersFlow({
|
||||
case 'placeholder': {
|
||||
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': {
|
||||
return <EmptyMemberList key={item.key} message={item.message} />
|
||||
}
|
||||
@@ -427,12 +475,24 @@ export function AddMembersFlow({
|
||||
renderItem={renderItems}
|
||||
ListHeaderComponent={listHeader}
|
||||
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}
|
||||
style={[
|
||||
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
|
||||
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}]}
|
||||
scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}}
|
||||
keyboardDismissMode="on-drag"
|
||||
@@ -448,7 +508,6 @@ export function AddMembersFlow({
|
||||
onPress={handlePressBack}>
|
||||
<ButtonIcon icon={ArrowLeftIcon} size="md" />
|
||||
<ButtonText>
|
||||
{' '}
|
||||
<Trans>Back</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {subDays} from 'date-fns'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '../Typography'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {localDateString} from './util'
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
@@ -56,12 +56,11 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.w_full, a.my_sm]}>
|
||||
<View style={[a.w_full, a.mt_md]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.text_center,
|
||||
t.atoms.bg,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.px_md,
|
||||
]}>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function EmojiReactionPicker({
|
||||
const t = useTheme()
|
||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||
const {measurement, close} = useContextMenuContext()
|
||||
const {align} = useContextMenuMenuContext()
|
||||
const {align, xOffset} = useContextMenuMenuContext()
|
||||
const [layout, setLayout] = useState({width: 0, height: 0})
|
||||
const {width: screenWidth} = useWindowDimensions()
|
||||
|
||||
@@ -44,12 +44,15 @@ export function EmojiReactionPicker({
|
||||
|
||||
const position = useMemo(() => {
|
||||
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,
|
||||
height: layout.height,
|
||||
width: layout.width,
|
||||
}
|
||||
}, [measurement, align, screenWidth, layout])
|
||||
}, [measurement, align, xOffset, screenWidth, layout])
|
||||
|
||||
const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
|
||||
|
||||
|
||||
@@ -25,15 +25,18 @@ import {usePromptControl} from '#/components/Prompt'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {EmojiReactionPicker} from './EmojiReactionPicker'
|
||||
import {hasReachedReactionLimit} from './util'
|
||||
|
||||
export let MessageContextMenu = ({
|
||||
message,
|
||||
senderProfile,
|
||||
children,
|
||||
onTap,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
senderProfile?: bsky.profile.AnyProfileView
|
||||
children: TriggerProps['children']
|
||||
onTap?: () => void
|
||||
}): React.ReactNode => {
|
||||
@@ -110,9 +113,7 @@ export let MessageContextMenu = ({
|
||||
[l, convo, message, currentAccount?.did],
|
||||
)
|
||||
|
||||
const sender = convo.convo.members.find(
|
||||
member => member.did === message.sender.did,
|
||||
)
|
||||
const sender = senderProfile
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -183,7 +184,7 @@ export let MessageContextMenu = ({
|
||||
control={reportControl}
|
||||
subject={{
|
||||
view: 'message',
|
||||
convoId: convo.convo.id,
|
||||
convoId: convo.convo.view.id,
|
||||
message,
|
||||
}}
|
||||
onAfterSubmit={() => {
|
||||
@@ -197,7 +198,7 @@ export let MessageContextMenu = ({
|
||||
control={blockOrDeleteControl}
|
||||
currentScreen="conversation"
|
||||
params={{
|
||||
convoId: convo.convo.id,
|
||||
convoId: convo.convo.view.id,
|
||||
message,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {memo, useCallback, useEffect, useMemo, useRef} from 'react'
|
||||
import {memo, useEffect, useMemo, useRef} from 'react'
|
||||
import {
|
||||
type GestureResponderEvent,
|
||||
LayoutAnimation,
|
||||
@@ -28,10 +28,8 @@ import {plural} from '@lingui/core/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
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 {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
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 {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {DateDivider} from './DateDivider'
|
||||
import {useDateDividerToggle} from './DateDividerToggle'
|
||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||
@@ -59,7 +56,9 @@ const DISPLAY_NAME_INSET = 22
|
||||
const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
|
||||
const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
|
||||
|
||||
function isWithinCluster({
|
||||
const TAP_AND_DRAG_DELAY_MS = 100
|
||||
|
||||
function isWithinClusterBoundary({
|
||||
isPending,
|
||||
adjacentMessage,
|
||||
isFromSameSender,
|
||||
@@ -92,62 +91,56 @@ function isWithinCluster({
|
||||
let MessageItem = ({
|
||||
item,
|
||||
isGroupChat = false,
|
||||
profile,
|
||||
}: {
|
||||
item: ConvoItem & {type: 'message' | 'pending-message'}
|
||||
isGroupChat?: boolean
|
||||
profile?: bsky.profile.AnyProfileView
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
const {t: l} = useLingui()
|
||||
const {convo} = useConvoActive()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const profile = item.relatedProfiles.get(item.message.sender.did)
|
||||
|
||||
const reactionsControl = useDialogControl()
|
||||
const reactionTapRef = useRef(false)
|
||||
|
||||
const {message, nextMessage, prevMessage} = item
|
||||
const isPending = item.type === 'pending-message'
|
||||
|
||||
const displayName = sanitizeDisplayName(
|
||||
profile?.displayName || sanitizeHandle(profile?.handle ?? ''),
|
||||
)
|
||||
const displayName = profile ? createSanitizedDisplayName(profile) : null
|
||||
|
||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||
const isFromSelf =
|
||||
message.sender?.did != null && message.sender.did === currentAccount?.did
|
||||
|
||||
const prevIsMessage = ChatBskyConvoDefs.isMessageView(prevMessage)
|
||||
const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage)
|
||||
|
||||
const isPrevFromSameSender =
|
||||
prevIsMessage && prevMessage.sender?.did === message.sender?.did
|
||||
prevIsMessage &&
|
||||
prevMessage.sender?.did === message.sender?.did &&
|
||||
message.sender?.did != null
|
||||
const isNextFromSameSender =
|
||||
nextIsMessage && nextMessage.sender?.did === message.sender?.did
|
||||
nextIsMessage &&
|
||||
nextMessage.sender?.did === message.sender?.did &&
|
||||
message.sender?.did != null
|
||||
|
||||
const isFirstInCluster = useMemo(
|
||||
() =>
|
||||
isWithinCluster({
|
||||
isPending,
|
||||
adjacentMessage: prevMessage,
|
||||
isFromSameSender: isPrevFromSameSender,
|
||||
currentSentAt: message.sentAt,
|
||||
direction: 'prev',
|
||||
}),
|
||||
[isPending, prevMessage, isPrevFromSameSender, message.sentAt],
|
||||
)
|
||||
const isFirstInCluster = isWithinClusterBoundary({
|
||||
isPending,
|
||||
adjacentMessage: prevMessage,
|
||||
isFromSameSender: isPrevFromSameSender,
|
||||
currentSentAt: message.sentAt,
|
||||
direction: 'prev',
|
||||
})
|
||||
|
||||
const isLastInCluster = useMemo(
|
||||
() =>
|
||||
isWithinCluster({
|
||||
isPending,
|
||||
adjacentMessage: nextMessage,
|
||||
isFromSameSender: isNextFromSameSender,
|
||||
currentSentAt: message.sentAt,
|
||||
direction: 'next',
|
||||
}),
|
||||
[isPending, nextMessage, isNextFromSameSender, message.sentAt],
|
||||
)
|
||||
const isLastInCluster = isWithinClusterBoundary({
|
||||
isPending,
|
||||
adjacentMessage: nextMessage,
|
||||
isFromSameSender: isNextFromSameSender,
|
||||
currentSentAt: message.sentAt,
|
||||
direction: 'next',
|
||||
})
|
||||
|
||||
const hasLargeGapFromPrev =
|
||||
!ChatBskyConvoDefs.isMessageView(prevMessage) ||
|
||||
@@ -159,7 +152,6 @@ let MessageItem = ({
|
||||
const isDateDividerToggled = isDividerToggled(message.id)
|
||||
const isNextDateDividerToggled =
|
||||
nextMessage != null && isDividerToggled(nextMessage.id)
|
||||
const showDateDivider = hasLargeGapFromPrev
|
||||
|
||||
const effectiveFirstInCluster = isFirstInCluster || isDateDividerToggled
|
||||
const effectiveLastInCluster = isLastInCluster || isNextDateDividerToggled
|
||||
@@ -168,18 +160,20 @@ let MessageItem = ({
|
||||
isInCluster && !effectiveFirstInCluster && !effectiveLastInCluster
|
||||
|
||||
const hasReactions = message.reactions && message.reactions.length > 0
|
||||
const prevHasReactions =
|
||||
prevIsMessage && prevMessage.reactions && prevMessage.reactions.length > 0
|
||||
const squaredBottomCorner =
|
||||
!hasReactions &&
|
||||
isInCluster &&
|
||||
(isInMiddleOfCluster || effectiveFirstInCluster)
|
||||
const squaredTopCorner =
|
||||
isInCluster && (isInMiddleOfCluster || effectiveLastInCluster)
|
||||
!prevHasReactions &&
|
||||
isInCluster &&
|
||||
(isInMiddleOfCluster || effectiveLastInCluster)
|
||||
|
||||
const pendingColor = t.palette.primary_300
|
||||
|
||||
const rt = useMemo(() => {
|
||||
return new RichTextAPI({text: message.text, facets: message.facets})
|
||||
}, [message.text, message.facets])
|
||||
const rt = new RichTextAPI({text: message.text, facets: message.facets})
|
||||
|
||||
const hasEmbedAndText =
|
||||
AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0
|
||||
@@ -219,27 +213,26 @@ let MessageItem = ({
|
||||
},
|
||||
)
|
||||
|
||||
const avatar = profile ? (
|
||||
<Link
|
||||
label={l`${sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
)}’s avatar`}
|
||||
accessibilityHint={l`Opens this profile`}
|
||||
to={makeProfileLink({
|
||||
did: profile.did,
|
||||
handle: profile.handle,
|
||||
})}
|
||||
onPress={() => unstableCacheProfileView(queryClient, profile)}>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
size={AVATAR_SIZE}
|
||||
moderationOpts={moderationOpts!}
|
||||
disabledPreview
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
|
||||
)
|
||||
const avatar =
|
||||
profile && moderationOpts ? (
|
||||
<Link
|
||||
label={l`${createSanitizedDisplayName(profile)}’s avatar`}
|
||||
accessibilityHint={l`Opens this profile`}
|
||||
to={makeProfileLink({
|
||||
did: profile.did,
|
||||
handle: profile.handle,
|
||||
})}
|
||||
onPress={() => unstableCacheProfileView(queryClient, profile)}>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
size={AVATAR_SIZE}
|
||||
moderationOpts={moderationOpts}
|
||||
disabledPreview
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
|
||||
)
|
||||
|
||||
const groupedReactions = useMemo(() => {
|
||||
const reactions = message.reactions ?? []
|
||||
@@ -281,11 +274,9 @@ let MessageItem = ({
|
||||
return l`You reacted ${reaction.value}`
|
||||
} else {
|
||||
const senderDid = reaction.sender.did
|
||||
const sender = convo.members.find(member => member.did === senderDid)
|
||||
if (sender) {
|
||||
return l`${sanitizeDisplayName(
|
||||
sender.displayName || sender.handle,
|
||||
)} reacted ${reaction.value}`
|
||||
const memberSender = item.relatedProfiles.get(senderDid)
|
||||
if (memberSender) {
|
||||
return l`${createSanitizedDisplayName(memberSender)} reacted ${reaction.value}`
|
||||
}
|
||||
return l`Someone reacted ${reaction.value}`
|
||||
}
|
||||
@@ -294,7 +285,13 @@ let MessageItem = ({
|
||||
one: '# person',
|
||||
other: '# people',
|
||||
})} reacted – ${groupedReactions.map(g => g.value).join(' ')}`
|
||||
}, [reactions, groupedReactions, currentAccount?.did, convo.members, l])
|
||||
}, [
|
||||
reactions,
|
||||
groupedReactions,
|
||||
currentAccount?.did,
|
||||
item.relatedProfiles,
|
||||
l,
|
||||
])
|
||||
|
||||
const appliedReactions = (
|
||||
<LayoutAnimationConfig skipEntering skipExiting>
|
||||
@@ -337,14 +334,16 @@ let MessageItem = ({
|
||||
// Include a delay here to account for tap-and-drag before release.
|
||||
setTimeout(() => {
|
||||
reactionTapRef.current = false
|
||||
}, 100)
|
||||
}, TAP_AND_DRAG_DELAY_MS)
|
||||
}}
|
||||
onPress={() => (isGroupChat ? reactionsControl.open() : undefined)}>
|
||||
onPress={isGroupChat ? reactionsControl.open : undefined}>
|
||||
{groupedReactions.map(group => (
|
||||
<Animated.View
|
||||
entering={native(ZoomIn.springify(200).delay(400))}
|
||||
exiting={
|
||||
groupedReactions.length > 1 && native(ZoomOut.delay(200))
|
||||
groupedReactions.length > 1
|
||||
? native(ZoomOut.delay(200))
|
||||
: undefined
|
||||
}
|
||||
layout={native(LinearTransition.delay(300))}
|
||||
key={group.value}
|
||||
@@ -377,7 +376,7 @@ let MessageItem = ({
|
||||
) : null}
|
||||
<ReactionsDialog
|
||||
control={reactionsControl}
|
||||
members={convo.members}
|
||||
relatedProfiles={item.relatedProfiles}
|
||||
message={message}
|
||||
reactions={message.reactions}
|
||||
groupedReactions={groupedReactions}
|
||||
@@ -393,13 +392,14 @@ let MessageItem = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{(showDateDivider || isDateDividerToggled) && (
|
||||
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
|
||||
<DateDivider date={message.sentAt} />
|
||||
</Animated.View>
|
||||
)}
|
||||
<View
|
||||
style={[messageInset, isFirstInCluster && !showDateDivider && a.mt_sm]}>
|
||||
<LayoutAnimationConfig skipExiting skipEntering>
|
||||
{(hasLargeGapFromPrev || isDateDividerToggled) && (
|
||||
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
|
||||
<DateDivider date={message.sentAt} />
|
||||
</Animated.View>
|
||||
)}
|
||||
</LayoutAnimationConfig>
|
||||
<View style={[messageInset, effectiveFirstInCluster && a.mt_md]}>
|
||||
<View style={[a.relative]}>
|
||||
{showAvatar ? (
|
||||
<View
|
||||
@@ -419,7 +419,7 @@ let MessageItem = ({
|
||||
a.flex_grow,
|
||||
!isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE},
|
||||
]}>
|
||||
{showDisplayName ? (
|
||||
{displayName && showDisplayName ? (
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
@@ -437,6 +437,7 @@ let MessageItem = ({
|
||||
hasReactions={hasReactions}
|
||||
isFromSelf={isFromSelf}
|
||||
message={message}
|
||||
senderProfile={profile}
|
||||
onTap={() => {
|
||||
if (reactionTapRef.current) return
|
||||
if (!hasLargeGapFromPrev) {
|
||||
@@ -454,7 +455,6 @@ let MessageItem = ({
|
||||
...(isOnlyEmoji(message.text)
|
||||
? []
|
||||
: [
|
||||
a.rounded_md,
|
||||
a.rounded_xl,
|
||||
a.py_sm,
|
||||
a.px_md,
|
||||
@@ -474,7 +474,23 @@ let MessageItem = ({
|
||||
]}>
|
||||
<RichText
|
||||
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}
|
||||
enableTags
|
||||
emojiMultiplier={3}
|
||||
@@ -517,16 +533,13 @@ let MessageItemMetadata = ({
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const handleRetry = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
if (item.type === 'pending-message' && item.retry) {
|
||||
e.preventDefault()
|
||||
item.retry()
|
||||
return false
|
||||
}
|
||||
},
|
||||
[item],
|
||||
)
|
||||
const handleRetry = (e: GestureResponderEvent) => {
|
||||
if (item.type === 'pending-message' && item.retry) {
|
||||
e.preventDefault()
|
||||
item.retry()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const errorColor = t.palette.negative_400
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ let MessageItemEmbed = ({
|
||||
<View
|
||||
style={[
|
||||
!isFromSelf && a.ml_sm,
|
||||
t.atoms.bg,
|
||||
native({
|
||||
flexBasis: 0,
|
||||
width: Math.min(screen.width, 600) / 1.4,
|
||||
|
||||
@@ -161,11 +161,13 @@ function GroupHeaderReady({
|
||||
})
|
||||
}
|
||||
|
||||
const lockStatus = convo.details.lockStatus
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
heading={
|
||||
<>
|
||||
<AvatarBubbles size="small" profiles={convo.members} />
|
||||
<AvatarBubbles size={40} profiles={convo.members} />
|
||||
<Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
|
||||
{convo.details.name}
|
||||
</Text>
|
||||
@@ -175,6 +177,7 @@ function GroupHeaderReady({
|
||||
settings={
|
||||
<Button
|
||||
label={l`Open group chat settings`}
|
||||
disabled={lockStatus === 'locked-permanently'}
|
||||
size="small"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
View,
|
||||
} from 'react-native'
|
||||
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 {HITSLOP_10} from '#/lib/constants'
|
||||
@@ -33,13 +33,13 @@ type Reaction = {
|
||||
|
||||
export function ReactionsDialog({
|
||||
control,
|
||||
members,
|
||||
relatedProfiles,
|
||||
message,
|
||||
reactions,
|
||||
groupedReactions,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
members: bsky.profile.AnyProfileView[]
|
||||
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
reactions?: ChatBskyConvoDefs.ReactionView[]
|
||||
groupedReactions?: Reaction[]
|
||||
@@ -100,7 +100,7 @@ export function ReactionsDialog({
|
||||
return 0
|
||||
})
|
||||
.map(reaction => {
|
||||
const sender = members.find(m => m.did === reaction.sender.did)
|
||||
const sender = relatedProfiles.get(reaction.sender.did)
|
||||
if (!sender) return null
|
||||
return (
|
||||
<ReactionRow
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -68,13 +68,13 @@ export type DirectConvoMember = ChatBskyActorDefs.ProfileViewBasic & {
|
||||
export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & (
|
||||
| {
|
||||
kind: 'group'
|
||||
details: ChatBskyConvoDefs.GroupConvo
|
||||
details: $Typed<ChatBskyConvoDefs.GroupConvo>
|
||||
primaryMember: GroupConvoMember // the owner
|
||||
members: Array<GroupConvoMember>
|
||||
}
|
||||
| {
|
||||
kind: 'direct'
|
||||
details: ChatBskyConvoDefs.DirectConvo
|
||||
details: $Typed<ChatBskyConvoDefs.DirectConvo>
|
||||
primaryMember: DirectConvoMember // the other user
|
||||
members: Array<DirectConvoMember>
|
||||
}
|
||||
@@ -110,14 +110,16 @@ export function parseConvoView(
|
||||
owner = member as GroupConvoMember
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
logger.warn(
|
||||
'Expected a GroupConvoMember, got an unknown kind of member',
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!owner) {
|
||||
throw new Error('No owner found in group convo')
|
||||
logger.warn('No owner found in group convo')
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -136,7 +138,8 @@ export function parseConvoView(
|
||||
const otherUser = convoView.members.find(m => m.did !== ownDid)
|
||||
|
||||
if (!otherUser) {
|
||||
throw new Error('No other user found in direct convo')
|
||||
logger.warn('No other user found in direct convo')
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -81,6 +81,7 @@ export type ItemProps = ViewStyleProp & {
|
||||
isInvalid?: boolean
|
||||
children: ((props: ItemState) => React.ReactNode) | React.ReactNode
|
||||
hitSlop?: PressableProps['hitSlop']
|
||||
highlightRow?: boolean
|
||||
}
|
||||
|
||||
export function useItemContext() {
|
||||
@@ -160,8 +161,11 @@ export function Item({
|
||||
style,
|
||||
type = 'checkbox',
|
||||
label,
|
||||
highlightRow,
|
||||
...rest
|
||||
}: ItemProps) {
|
||||
const t = useTheme()
|
||||
|
||||
const {
|
||||
values: selectedValues,
|
||||
type: groupType,
|
||||
@@ -207,6 +211,12 @@ export function Item({
|
||||
[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 (
|
||||
<ItemContext.Provider value={state}>
|
||||
<Pressable
|
||||
@@ -232,7 +242,7 @@ export function Item({
|
||||
onPressOut={onPressOut}
|
||||
onFocus={onFocus}
|
||||
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}
|
||||
</Pressable>
|
||||
</ItemContext.Provider>
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
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',
|
||||
})
|
||||
|
||||
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',
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
/**
|
||||
* @deprecated Use EditBig_Stroke2_Corner2_Rounded
|
||||
*/
|
||||
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',
|
||||
})
|
||||
|
||||
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',
|
||||
})
|
||||
|
||||
@@ -7,3 +7,7 @@ export const Lock_Stroke2_Corner0_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',
|
||||
})
|
||||
|
||||
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'
|
||||
|
||||
/**
|
||||
* @deprecated Use SquareBehindSquare_Stroke2_Corner2_Rounded
|
||||
*/
|
||||
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',
|
||||
})
|
||||
|
||||
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',
|
||||
})
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import {useCallback, useInsertionEffect, useRef} from 'react'
|
||||
|
||||
// This should be used sparingly. It erases reactivity, i.e. when the inputs
|
||||
// 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"
|
||||
// to this change (e.g. by knowing to call your function again).
|
||||
//
|
||||
// Also, you should avoid calling the returned function during rendering
|
||||
// since the values captured by it are going to lag behind.
|
||||
export function useNonReactiveCallback<T extends Function>(fn: T): T {
|
||||
const ref = useRef(fn)
|
||||
const noop = () => {}
|
||||
|
||||
/**
|
||||
* This should be used sparingly. It erases reactivity, i.e. when the inputs
|
||||
* 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" to
|
||||
* this change (e.g. by knowing to call your function again).
|
||||
*
|
||||
* 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(() => {
|
||||
ref.current = fn
|
||||
ref.current = (fn ?? noop) as T
|
||||
}, [fn])
|
||||
return useCallback(
|
||||
(...args: any) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export type NotificationReason =
|
||||
| 'reply'
|
||||
| 'quote'
|
||||
| 'chat-message'
|
||||
| 'chat-reaction'
|
||||
| 'starterpack-joined'
|
||||
| 'like-via-repost'
|
||||
| 'repost-via-repost'
|
||||
@@ -44,7 +45,7 @@ export type NotificationReason =
|
||||
export type NotificationPayload =
|
||||
| undefined
|
||||
| {
|
||||
reason: Exclude<NotificationReason, 'chat-message'>
|
||||
reason: Exclude<NotificationReason, 'chat-message' | 'chat-reaction'>
|
||||
uri: string
|
||||
subject: string
|
||||
recipientDid: string
|
||||
@@ -55,6 +56,12 @@ export type NotificationPayload =
|
||||
messageId: string
|
||||
recipientDid: string
|
||||
}
|
||||
| {
|
||||
reason: 'chat-reaction'
|
||||
convoId: string
|
||||
messageId: string
|
||||
recipientDid: string
|
||||
}
|
||||
|
||||
const DEFAULT_HANDLER_OPTIONS = {
|
||||
shouldShowBanner: false,
|
||||
@@ -192,8 +199,11 @@ export function useNotificationsHandler() {
|
||||
const handleNotification = (payload?: NotificationPayload) => {
|
||||
if (!payload) return
|
||||
|
||||
if (payload.reason === 'chat-message') {
|
||||
logger.debug(`useNotificationsHandler: handling chat message`, {
|
||||
if (
|
||||
payload.reason === 'chat-message' ||
|
||||
payload.reason === 'chat-reaction'
|
||||
) {
|
||||
logger.debug(`useNotificationsHandler: handling chat notification`, {
|
||||
payload,
|
||||
})
|
||||
|
||||
@@ -270,7 +280,8 @@ export function useNotificationsHandler() {
|
||||
logger.debug('useNotificationsHandler: incoming', {e, payload})
|
||||
|
||||
if (
|
||||
payload.reason === 'chat-message' &&
|
||||
(payload.reason === 'chat-message' ||
|
||||
payload.reason === 'chat-reaction') &&
|
||||
payload.recipientDid === currentAccount?.did
|
||||
) {
|
||||
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 currentAccount changes, we should try to handle it again.
|
||||
if (
|
||||
storedAccountSwitchPayload?.reason === 'chat-message' &&
|
||||
(storedAccountSwitchPayload?.reason === 'chat-message' ||
|
||||
storedAccountSwitchPayload?.reason === 'chat-reaction') &&
|
||||
currentAccount?.did === storedAccountSwitchPayload.recipientDid
|
||||
) {
|
||||
handleNotification(storedAccountSwitchPayload)
|
||||
@@ -429,6 +441,7 @@ export function notificationToURL(payload: NotificationPayload): string | null {
|
||||
return `/profile/${urip.host}`
|
||||
}
|
||||
case 'chat-message':
|
||||
case 'chat-reaction':
|
||||
// should be handled separately
|
||||
return null
|
||||
case 'verified':
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+1452
-969
File diff suppressed because it is too large
Load Diff
+1428
-945
File diff suppressed because it is too large
Load Diff
+1451
-968
File diff suppressed because it is too large
Load Diff
+1428
-945
File diff suppressed because it is too large
Load Diff
+1428
-945
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1454
-971
File diff suppressed because it is too large
Load Diff
+1454
-971
File diff suppressed because it is too large
Load Diff
+1454
-971
File diff suppressed because it is too large
Load Diff
+1454
-971
File diff suppressed because it is too large
Load Diff
+1452
-969
File diff suppressed because it is too large
Load Diff
+1455
-972
File diff suppressed because it is too large
Load Diff
+869
-624
File diff suppressed because it is too large
Load Diff
+1458
-975
File diff suppressed because it is too large
Load Diff
+1452
-969
File diff suppressed because it is too large
Load Diff
+1451
-968
File diff suppressed because it is too large
Load Diff
+1452
-969
File diff suppressed because it is too large
Load Diff
+1458
-975
File diff suppressed because it is too large
Load Diff
+1452
-969
File diff suppressed because it is too large
Load Diff
+1452
-969
File diff suppressed because it is too large
Load Diff
+1454
-971
File diff suppressed because it is too large
Load Diff
+1452
-969
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1452
-969
File diff suppressed because it is too large
Load Diff
+1451
-968
File diff suppressed because it is too large
Load Diff
+1452
-969
File diff suppressed because it is too large
Load Diff
+1452
-969
File diff suppressed because it is too large
Load Diff
+1450
-967
File diff suppressed because it is too large
Load Diff
+1454
-971
File diff suppressed because it is too large
Load Diff
+1428
-945
File diff suppressed because it is too large
Load Diff
+1452
-969
File diff suppressed because it is too large
Load Diff
+1459
-976
File diff suppressed because it is too large
Load Diff
+1428
-945
File diff suppressed because it is too large
Load Diff
+1452
-969
File diff suppressed because it is too large
Load Diff
+1452
-969
File diff suppressed because it is too large
Load Diff
+1452
-969
File diff suppressed because it is too large
Load Diff
+1454
-971
File diff suppressed because it is too large
Load Diff
+1453
-970
File diff suppressed because it is too large
Load Diff
+1452
-969
File diff suppressed because it is too large
Load Diff
+1452
-969
File diff suppressed because it is too large
Load Diff
+1453
-970
File diff suppressed because it is too large
Load Diff
+1452
-969
File diff suppressed because it is too large
Load Diff
+1458
-975
File diff suppressed because it is too large
Load Diff
+1452
-969
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
Reference in New Issue
Block a user