Merge branch 'main' into app-2067
This commit is contained in:
@@ -109,23 +109,46 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
if [ -f "build.tar.gz" ]; then
|
if [ -f "build.tar.gz" ]; then
|
||||||
echo "Extracting build.tar.gz..."
|
echo "Extracting build.tar.gz..."
|
||||||
mkdir ios-build
|
rm -rf ios-build
|
||||||
|
mkdir -p ios-build
|
||||||
tar -xzf build.tar.gz -C ios-build
|
tar -xzf build.tar.gz -C ios-build
|
||||||
echo "Extraction completed successfully"
|
echo "Extraction completed successfully"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Top-level extracted files:"
|
||||||
|
find ios-build -maxdepth 3 -print
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Searching for IPA..."
|
||||||
|
IPA_PATH="$(find ios-build -type f -name '*.ipa' -print -quit)"
|
||||||
|
if [ -z "$IPA_PATH" ]; then
|
||||||
|
echo "ERROR: No .ipa found anywhere under ios-build."
|
||||||
|
echo "Archive contents:"
|
||||||
|
tar -tzf build.tar.gz | sed -n '1,200p'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUILD_DIR="$(dirname "$IPA_PATH")"
|
||||||
|
echo "Found IPA at: $IPA_PATH"
|
||||||
|
echo "Build dir: $BUILD_DIR"
|
||||||
|
echo ""
|
||||||
|
echo "Build dir contents:"
|
||||||
|
ls -la "$BUILD_DIR"
|
||||||
|
echo "BUILD_DIR=$BUILD_DIR" >> $GITHUB_ENV
|
||||||
else
|
else
|
||||||
echo "Archive file not found!"
|
echo "Archive file not found!"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: 🚀 Deploy
|
- name: 🚀 Deploy
|
||||||
run: eas submit -p ios --non-interactive --path ios-build/ios/build/Bluesky.ipa
|
run: eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
|
||||||
|
|
||||||
- name: 🪲 Upload dSYM to Sentry
|
- name: 🪲 Upload dSYM to Sentry
|
||||||
run: >
|
run: >
|
||||||
SENTRY_ORG=blueskyweb
|
SENTRY_ORG=blueskyweb
|
||||||
SENTRY_PROJECT=app
|
SENTRY_PROJECT=app
|
||||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
yarn sentry-cli debug-files upload ios-build/ios/build/Bluesky.app.dSYM.zip --include-sources
|
yarn sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
|
||||||
|
|
||||||
- name: 📚 Get version from package.json
|
- name: 📚 Get version from package.json
|
||||||
id: get-build-info
|
id: get-build-info
|
||||||
|
|||||||
@@ -239,10 +239,52 @@ jobs:
|
|||||||
yarn use-build-number-with-bump
|
yarn use-build-number-with-bump
|
||||||
eas build -p ios
|
eas build -p ios
|
||||||
--profile testflight
|
--profile testflight
|
||||||
--local --output build.ipa --non-interactive
|
--local --output build.tar.gz --non-interactive
|
||||||
|
|
||||||
|
- name: 📂 Extract build artifact
|
||||||
|
run: |
|
||||||
|
if [ -f "build.tar.gz" ]; then
|
||||||
|
echo "Extracting build.tar.gz..."
|
||||||
|
rm -rf ios-build
|
||||||
|
mkdir -p ios-build
|
||||||
|
tar -xzf build.tar.gz -C ios-build
|
||||||
|
echo "Extraction completed successfully"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Top-level extracted files:"
|
||||||
|
find ios-build -maxdepth 3 -print
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Searching for IPA..."
|
||||||
|
IPA_PATH="$(find ios-build -type f -name '*.ipa' -print -quit)"
|
||||||
|
if [ -z "$IPA_PATH" ]; then
|
||||||
|
echo "ERROR: No .ipa found anywhere under ios-build."
|
||||||
|
echo "Archive contents:"
|
||||||
|
tar -tzf build.tar.gz | sed -n '1,200p'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUILD_DIR="$(dirname "$IPA_PATH")"
|
||||||
|
echo "Found IPA at: $IPA_PATH"
|
||||||
|
echo "Build dir: $BUILD_DIR"
|
||||||
|
echo ""
|
||||||
|
echo "Build dir contents:"
|
||||||
|
ls -la "$BUILD_DIR"
|
||||||
|
echo "BUILD_DIR=$BUILD_DIR" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "Archive file not found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: 🚀 Deploy
|
- name: 🚀 Deploy
|
||||||
run: eas submit -p ios --non-interactive --path build.ipa
|
run: eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
|
||||||
|
|
||||||
|
- name: 🪲 Upload dSYM to Sentry
|
||||||
|
run: >
|
||||||
|
SENTRY_ORG=blueskyweb
|
||||||
|
SENTRY_PROJECT=app
|
||||||
|
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
yarn sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
|
||||||
|
|
||||||
- name: ⬇️ Restore Cache
|
- name: ⬇️ Restore Cache
|
||||||
id: get-base-commit
|
id: get-base-commit
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# CLAUDE.md - Bluesky Social App Development Guide
|
# CLAUDE.md – Bluesky Social App Development Guide
|
||||||
|
|
||||||
This document provides guidance for working effectively in the Bluesky Social app codebase.
|
This document provides guidance for working effectively in the Bluesky Social app codebase.
|
||||||
|
|
||||||
@@ -7,13 +7,17 @@ This document provides guidance for working effectively in the Bluesky Social ap
|
|||||||
Bluesky Social is a cross-platform social media application built with React Native and Expo. It runs on iOS, Android, and Web, connecting to the AT Protocol (atproto) decentralized social network.
|
Bluesky Social is a cross-platform social media application built with React Native and Expo. It runs on iOS, Android, and Web, connecting to the AT Protocol (atproto) decentralized social network.
|
||||||
|
|
||||||
**Tech Stack:**
|
**Tech Stack:**
|
||||||
|
|
||||||
|
- React 19.1
|
||||||
- React Native 0.81 with Expo 54
|
- React Native 0.81 with Expo 54
|
||||||
- TypeScript
|
- TypeScript 6
|
||||||
- React Navigation for routing
|
- React Navigation 7 for routing
|
||||||
- TanStack Query (React Query) for data fetching
|
- TanStack Query (React Query) for data fetching
|
||||||
- Lingui for internationalization
|
- Lingui 5 for internationalization
|
||||||
- Custom design system called ALF (Application Layout Framework)
|
- Custom design system called ALF (Application Layout Framework)
|
||||||
|
|
||||||
|
Prefer using the latest features available for each of these libraries (exact versions are found in `package.json`). For example, prefer `@lingui/react/macro` over `@lingui/react`. Suggest refactoring legacy or deprecated uses.
|
||||||
|
|
||||||
## Essential Commands
|
## Essential Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -162,6 +166,10 @@ its own looks more like a single component file.
|
|||||||
|
|
||||||
### Documentation and Tests Within Features
|
### Documentation and Tests Within Features
|
||||||
|
|
||||||
|
Comment code when necessary to explain the “why” behind something; avoid
|
||||||
|
comments that simply describe the code. Avoid Unicode characters in comments,
|
||||||
|
e.g., use `-` not `—`.
|
||||||
|
|
||||||
For larger features or components, it's helpful to include a README.md file
|
For larger features or components, it's helpful to include a README.md file
|
||||||
within the directory that explains the purpose of the feature, how it works, and
|
within the directory that explains the purpose of the feature, how it works, and
|
||||||
any important implementation details. The `/Component/index.tsx` pattern lends
|
any important implementation details. The `/Component/index.tsx` pattern lends
|
||||||
@@ -182,6 +190,14 @@ ALF is the custom design system. It uses Tailwind-inspired naming with underscor
|
|||||||
|
|
||||||
### Basic Usage
|
### Basic Usage
|
||||||
|
|
||||||
|
Generally, order atoms by:
|
||||||
|
|
||||||
|
- Flexbox configuration, e.g., `a.flex_row`
|
||||||
|
- Spacing, e.g., `a.px_md`
|
||||||
|
- Text styles, e.g., `a.font_bold`
|
||||||
|
- Themes, e.g., `t.atoms.text`,
|
||||||
|
- Raw styles, e.g., `{backgroundColor: t.palette.primary_500}`
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
|
||||||
@@ -190,9 +206,7 @@ function MyComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
|
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
|
||||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>Hello</Text>
|
||||||
Hello
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -200,20 +214,23 @@ function MyComponent() {
|
|||||||
|
|
||||||
### Key Concepts
|
### Key Concepts
|
||||||
|
|
||||||
**Static Atoms** - Theme-independent styles imported from `atoms`:
|
**Static Atoms** – Theme-independent styles imported from `atoms`:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
|
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
|
||||||
```
|
```
|
||||||
|
|
||||||
**Theme Atoms** - Theme-dependent colors from `useTheme()`:
|
**Theme Atoms** – Theme-dependent colors from `useTheme()`:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
|
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
|
||||||
// t.palette.primary_500, t.palette.negative_400, etc.
|
// t.palette.primary_500, t.palette.negative_400, etc.
|
||||||
```
|
```
|
||||||
|
|
||||||
**Platform Utilities** - For platform-specific styles:
|
**Platform Utilities** – For platform-specific styles:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import {web, native, ios, android, platform} from '#/alf'
|
import {web, native, ios, android, platform} from '#/alf'
|
||||||
|
|
||||||
@@ -225,7 +242,8 @@ const styles = [
|
|||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Breakpoints** - Responsive design:
|
**Breakpoints** – Responsive design:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import {useBreakpoints} from '#/alf'
|
import {useBreakpoints} from '#/alf'
|
||||||
|
|
||||||
@@ -245,6 +263,38 @@ if (gtMobile) {
|
|||||||
|
|
||||||
## Component Patterns
|
## Component Patterns
|
||||||
|
|
||||||
|
- Prefer fragment shorthand over `Fragment` unless a `key` is needed.
|
||||||
|
- Prefer functions over arrow functions for component declarations.
|
||||||
|
- Prefer prop destructuring via parameters over a const within the component.
|
||||||
|
- Prefer inline types over `Props` types or interfaces.
|
||||||
|
- Set reasonable defaults for optional props.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {Fragment} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
function MyComponent({foo = []}: {foo?: string[]}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View><Text><Trans>Example</Trans><Text></View>
|
||||||
|
<View>
|
||||||
|
{foo.map((foo, index) => (
|
||||||
|
<Fragment key={foo}>
|
||||||
|
<Text>{index}</Text>
|
||||||
|
<Text>{foo}</Text>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Dialog Component
|
### Dialog Component
|
||||||
|
|
||||||
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
|
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
|
||||||
@@ -263,23 +313,29 @@ function MyFeature() {
|
|||||||
|
|
||||||
<Dialog.Outer control={control}>
|
<Dialog.Outer control={control}>
|
||||||
{/* Typically the inner part is in its own component */}
|
{/* Typically the inner part is in its own component */}
|
||||||
<Dialog.Handle /> {/* Native-only drag handle */}
|
<DialogInner />
|
||||||
<Dialog.ScrollableInner label={_(msg`My Dialog`)}>
|
|
||||||
<Dialog.Header>
|
|
||||||
<Dialog.HeaderText>Title</Dialog.HeaderText>
|
|
||||||
</Dialog.Header>
|
|
||||||
|
|
||||||
<Text>Dialog content here</Text>
|
|
||||||
|
|
||||||
<Button label="Done" onPress={() => control.close()}>
|
|
||||||
<ButtonText>Done</ButtonText>
|
|
||||||
</Button>
|
|
||||||
<Dialog.Close /> {/* Web-only X button in top left */}
|
|
||||||
</Dialog.ScrollableInner>
|
|
||||||
</Dialog.Outer>
|
</Dialog.Outer>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DialogInner() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog.Handle /> {/* Native-only drag handle */}
|
||||||
|
<Dialog.ScrollableInner label={l`My Dialog`}>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.HeaderText>Title</Dialog.HeaderText>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Text>Dialog content here</Text>
|
||||||
|
<Button label="Done" onPress={() => control.close()}>
|
||||||
|
<ButtonText>Done</ButtonText>
|
||||||
|
</Button>
|
||||||
|
<Dialog.Close /> {/* Web-only X button in top left */}
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Menu Component
|
### Menu Component
|
||||||
@@ -345,6 +401,7 @@ import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Button Props:**
|
**Button Props:**
|
||||||
|
|
||||||
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
|
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
|
||||||
- `size`: `'tiny'` | `'small'` | `'large'`
|
- `size`: `'tiny'` | `'small'` | `'large'`
|
||||||
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
|
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
|
||||||
@@ -384,39 +441,66 @@ import * as TextField from '#/components/forms/TextField'
|
|||||||
|
|
||||||
## Internationalization (i18n)
|
## Internationalization (i18n)
|
||||||
|
|
||||||
All user-facing strings must be wrapped for translation using Lingui.
|
All user-facing strings must be wrapped for translation using Lingui. Include `comment` and/or `context` props when necessary to avoid ambiguity, e.g., “Post” as a noun vs a verb.
|
||||||
|
|
||||||
|
Prefer using `t` via `import {useLingui} '@lingui/react/macro'` vs `_` via `import {useLingui} from '@lingui/react'`. Alias `t` to `l` to avoid collisions with `const t = useTheme()`. Refactor existing uses of ``_(msg`foo`)`` to use `` l`foo` ``.
|
||||||
|
|
||||||
|
Prefer Unicode punctuation over keyboard punctuation, e.g., `“quote”` over `"quote"`. Prefer en dashes preceded by a non-breaking space over em dashes, e.g., `one – two` over `one—two`.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import {msg, plural} from '@lingui/core/macro'
|
import {plural} from '@lingui/core/macro'
|
||||||
import {Trans} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
|
|
||||||
function MyComponent() {
|
function MyComponent() {
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
// Simple strings - use msg() with _() function
|
// Simple strings - use the l macro
|
||||||
const title = _(msg`Settings`)
|
const title = l`Settings`
|
||||||
const errorMessage = _(msg`Something went wrong`)
|
const errorMessage = l({
|
||||||
|
message: 'Something went wrong',
|
||||||
|
comment: 'Generic error message for unknown/unhandled errors.',
|
||||||
|
context: 'Toast',
|
||||||
|
})
|
||||||
|
|
||||||
// Strings with variables
|
// Strings with variables
|
||||||
const greeting = _(msg`Hello, ${name}!`)
|
const greeting = l`Hello, ${name}!`
|
||||||
|
|
||||||
// Pluralization
|
// Pluralization
|
||||||
const countLabel = _(plural(count, {
|
const countLabel = plural(count, {
|
||||||
one: '# item',
|
one: '# item',
|
||||||
other: '# items',
|
other: '# items',
|
||||||
}))
|
})
|
||||||
|
|
||||||
// JSX content - use Trans component
|
// JSX content - use Trans component
|
||||||
return (
|
return (
|
||||||
<Text>
|
<Text>
|
||||||
<Trans>Welcome to <Text style={a.font_bold}>Bluesky</Text></Trans>
|
<Trans>
|
||||||
|
Welcome to <Text style={a.font_bold}>Bluesky</Text>, {name}!
|
||||||
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Prefer `i18n.date` for date and time formatting. This ensures formatting is re-applied when the language changes at runtime. Refactor existing uses of `Intl.DateTimeFormat` to use `i18n.date`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const {i18n} = useLingui()
|
||||||
|
|
||||||
|
const createdAt = new Date()
|
||||||
|
|
||||||
|
return i18n.date(createdAt, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'medium',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**Commands:**
|
**Commands:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# DO NOT run these commands - extraction and compilation are handled by a nightly CI job
|
# DO NOT run these commands - extraction and compilation are handled by a nightly CI job
|
||||||
yarn intl:extract # Extract new strings to locale files
|
yarn intl:extract # Extract new strings to locale files
|
||||||
@@ -473,7 +557,7 @@ export function useProfileMutation() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (data) => {
|
mutationFn: async data => {
|
||||||
// Update logic
|
// Update logic
|
||||||
},
|
},
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
@@ -481,7 +565,7 @@ export function useProfileMutation() {
|
|||||||
queryKey: createProfileQueryKey({did: variables.did}),
|
queryKey: createProfileQueryKey({did: variables.did}),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: error => {
|
||||||
if (isNetworkError(error)) {
|
if (isNetworkError(error)) {
|
||||||
// don't log, but inform user
|
// don't log, but inform user
|
||||||
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
|
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
|
||||||
@@ -490,7 +574,7 @@ export function useProfileMutation() {
|
|||||||
// Log unexpected errors to Sentry
|
// Log unexpected errors to Sentry
|
||||||
logger.error('Error updating profile', {safeMessage: error})
|
logger.error('Error updating profile', {safeMessage: error})
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,21 +589,25 @@ export function useProfileCacheMutation() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return (data: Partial<Profile>) => {
|
return (data: Partial<Profile>) => {
|
||||||
queryClient.setQueryData(createProfileQueryKey({did: data.did}), oldData => {
|
queryClient.setQueryData(
|
||||||
if (!oldData) return oldData
|
createProfileQueryKey({did: data.did}),
|
||||||
return {...oldData, ...data}
|
oldData => {
|
||||||
})
|
if (!oldData) return oldData
|
||||||
|
return {...oldData, ...data}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Stale Time Constants** (from `src/state/queries/index.ts`):
|
**Stale Time Constants** (from `src/state/queries/index.ts`):
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
STALE.SECONDS.FIFTEEN // 15 seconds
|
STALE.SECONDS.FIFTEEN // 15 seconds
|
||||||
STALE.MINUTES.ONE // 1 minute
|
STALE.MINUTES.ONE // 1 minute
|
||||||
STALE.MINUTES.FIVE // 5 minutes
|
STALE.MINUTES.FIVE // 5 minutes
|
||||||
STALE.HOURS.ONE // 1 hour
|
STALE.HOURS.ONE // 1 hour
|
||||||
STALE.INFINITY // Never stale
|
STALE.INFINITY // Never stale
|
||||||
```
|
```
|
||||||
|
|
||||||
**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
|
**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
|
||||||
@@ -565,12 +653,7 @@ function SettingsScreen() {
|
|||||||
const autoplayDisabled = useAutoplayDisabled()
|
const autoplayDisabled = useAutoplayDisabled()
|
||||||
const setAutoplayDisabled = useSetAutoplayDisabled()
|
const setAutoplayDisabled = useSetAutoplayDisabled()
|
||||||
|
|
||||||
return (
|
return <Toggle value={autoplayDisabled} onValueChange={setAutoplayDisabled} />
|
||||||
<Toggle
|
|
||||||
value={autoplayDisabled}
|
|
||||||
onValueChange={setAutoplayDisabled}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -604,13 +687,9 @@ import {type CommonNavigatorParams} from '#/lib/routes/types'
|
|||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
|
||||||
|
|
||||||
export function ProfileScreen({route, navigation}: Props) {
|
export function ProfileScreen({route, navigation}: Props) {
|
||||||
const {name} = route.params // Type-safe params
|
const {name} = route.params // Type-safe params
|
||||||
|
|
||||||
return (
|
return <Layout.Screen>{/* Screen content */}</Layout.Screen>
|
||||||
<Layout.Screen>
|
|
||||||
{/* Screen content */}
|
|
||||||
</Layout.Screen>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Programmatic navigation
|
// Programmatic navigation
|
||||||
@@ -637,8 +716,9 @@ Component.android.tsx # Android-only
|
|||||||
```
|
```
|
||||||
|
|
||||||
Example from Dialog:
|
Example from Dialog:
|
||||||
- `src/components/Dialog/index.tsx` - Native (uses BottomSheet)
|
|
||||||
- `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives)
|
- `src/components/Dialog/index.tsx` – Native (uses BottomSheet)
|
||||||
|
- `src/components/Dialog/index.web.tsx` – Web (uses modal with Radix primitives)
|
||||||
|
|
||||||
**Important:** The bundler automatically resolves platform-specific files. Just import normally:
|
**Important:** The bundler automatically resolves platform-specific files. Just import normally:
|
||||||
|
|
||||||
@@ -653,6 +733,7 @@ const storage = IS_NATIVE
|
|||||||
```
|
```
|
||||||
|
|
||||||
Platform detection (for runtime logic, not imports):
|
Platform detection (for runtime logic, not imports):
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
|
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
|
||||||
|
|
||||||
@@ -687,13 +768,13 @@ Common pitfalls to avoid in this codebase:
|
|||||||
// WRONG - causes bugs with state updates, navigation, opening other dialogs
|
// WRONG - causes bugs with state updates, navigation, opening other dialogs
|
||||||
const onConfirm = () => {
|
const onConfirm = () => {
|
||||||
control.close()
|
control.close()
|
||||||
navigation.navigate('Home') // May race with dialog animation
|
navigation.navigate('Home') // May race with dialog animation
|
||||||
}
|
}
|
||||||
|
|
||||||
// WRONG - same problem
|
// WRONG - same problem
|
||||||
const onConfirm = () => {
|
const onConfirm = () => {
|
||||||
control.close()
|
control.close()
|
||||||
otherDialogControl.open() // Will likely fail or cause visual glitches
|
otherDialogControl.open() // Will likely fail or cause visual glitches
|
||||||
}
|
}
|
||||||
|
|
||||||
// CORRECT - action runs after dialog fully closes
|
// CORRECT - action runs after dialog fully closes
|
||||||
@@ -720,12 +801,13 @@ const onConfirm = () => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
This applies to:
|
This applies to:
|
||||||
|
|
||||||
- Navigation (`navigation.navigate()`, `navigation.push()`)
|
- Navigation (`navigation.navigate()`, `navigation.push()`)
|
||||||
- Opening other dialogs or menus
|
- Opening other dialogs or menus
|
||||||
- State updates that affect UI (`setState`, `queryClient.invalidateQueries`)
|
- State updates that affect UI (`setState`, `queryClient.invalidateQueries`)
|
||||||
- Callbacks passed from parent components
|
- Callbacks passed from parent components
|
||||||
|
|
||||||
The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`.
|
The Menu component on iOS specifically uses this pattern – see `src/components/Menu/index.tsx:151`.
|
||||||
|
|
||||||
### Controlled vs Uncontrolled Inputs
|
### Controlled vs Uncontrolled Inputs
|
||||||
|
|
||||||
@@ -748,10 +830,11 @@ Prefer `defaultValue` over `value` for TextInput on the old architecture:
|
|||||||
### Platform-Specific Behavior
|
### Platform-Specific Behavior
|
||||||
|
|
||||||
Some components behave differently across platforms:
|
Some components behave differently across platforms:
|
||||||
- `Dialog.Handle` - Only renders on native (drag handle for bottom sheet)
|
|
||||||
- `Dialog.Close` - Only renders on web (X button)
|
- `Dialog.Handle` – Only renders on native (drag handle for bottom sheet)
|
||||||
- `Menu.Divider` - Only renders on web
|
- `Dialog.Close` – Only renders on web (X button)
|
||||||
- `Menu.ContainerItem` - Only works on native
|
- `Menu.Divider` – Only renders on web
|
||||||
|
- `Menu.ContainerItem` – Only works on native
|
||||||
|
|
||||||
Always test on multiple platforms when using these components.
|
Always test on multiple platforms when using these components.
|
||||||
|
|
||||||
@@ -772,6 +855,7 @@ const handlePress = () => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
Only use `useMemo`/`useCallback` when you have a specific reason, such as:
|
Only use `useMemo`/`useCallback` when you have a specific reason, such as:
|
||||||
|
|
||||||
- The value is immediately used in an effect's dependency array
|
- The value is immediately used in an effect's dependency array
|
||||||
- You're passing a callback to a non-React library that needs referential stability
|
- You're passing a callback to a non-React library that needs referential stability
|
||||||
|
|
||||||
@@ -779,7 +863,7 @@ Only use `useMemo`/`useCallback` when you have a specific reason, such as:
|
|||||||
|
|
||||||
1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful
|
1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful
|
||||||
|
|
||||||
2. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>`
|
2. **Translations**: Wrap ALL user-facing strings with ` `l` `` or `<Trans>`
|
||||||
|
|
||||||
3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles
|
3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles
|
||||||
|
|
||||||
@@ -793,14 +877,14 @@ Only use `useMemo`/`useCallback` when you have a specific reason, such as:
|
|||||||
|
|
||||||
## Key Files Reference
|
## Key Files Reference
|
||||||
|
|
||||||
| Purpose | Location |
|
| Purpose | Location |
|
||||||
|---------|----------|
|
| ----------------- | -------------------------------------------- |
|
||||||
| Theme definitions | `src/alf/themes.ts` |
|
| Theme definitions | `src/alf/themes.ts` |
|
||||||
| Design tokens | `src/alf/tokens.ts` |
|
| Design tokens | `src/alf/tokens.ts` |
|
||||||
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
|
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
|
||||||
| Navigation config | `src/Navigation.tsx` |
|
| Navigation config | `src/Navigation.tsx` |
|
||||||
| Route definitions | `src/routes.ts` |
|
| Route definitions | `src/routes.ts` |
|
||||||
| Route types | `src/lib/routes/types.ts` |
|
| Route types | `src/lib/routes/types.ts` |
|
||||||
| Query hooks | `src/state/queries/*.ts` |
|
| Query hooks | `src/state/queries/*.ts` |
|
||||||
| Session state | `src/state/session/index.tsx` |
|
| Session state | `src/state/session/index.tsx` |
|
||||||
| i18n setup | `src/locale/i18n.ts` |
|
| i18n setup | `src/locale/i18n.ts` |
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ module.exports = function (_config) {
|
|||||||
infoPlist: {
|
infoPlist: {
|
||||||
CADisableMinimumFrameDurationOnPhone: true,
|
CADisableMinimumFrameDurationOnPhone: true,
|
||||||
UIBackgroundModes: ['remote-notification'],
|
UIBackgroundModes: ['remote-notification'],
|
||||||
|
NSUserActivityTypes: ['INSendMessageIntent'],
|
||||||
NSCameraUsageDescription:
|
NSCameraUsageDescription:
|
||||||
'Used for profile pictures, posts, and other kinds of content.',
|
'Used for profile pictures, posts, and other kinds of content.',
|
||||||
NSMicrophoneUsageDescription:
|
NSMicrophoneUsageDescription:
|
||||||
@@ -123,6 +124,7 @@ module.exports = function (_config) {
|
|||||||
'com.apple.developer.kernel.increased-memory-limit': true,
|
'com.apple.developer.kernel.increased-memory-limit': true,
|
||||||
'com.apple.developer.kernel.extended-virtual-addressing': true,
|
'com.apple.developer.kernel.extended-virtual-addressing': true,
|
||||||
'com.apple.security.application-groups': 'group.app.bsky',
|
'com.apple.security.application-groups': 'group.app.bsky',
|
||||||
|
'com.apple.developer.usernotifications.communication': true,
|
||||||
// 'com.apple.developer.device-information.user-assigned-device-name': true,
|
// 'com.apple.developer.device-information.user-assigned-device-name': true,
|
||||||
},
|
},
|
||||||
privacyManifests: {
|
privacyManifests: {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M17 3a4 4 0 0 1 4 4v10a4 4 0 0 1-4 4h-2a1 1 0 1 1 0-2h2a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2a1 1 0 1 1 0-2h2Zm-6.707 4.793a1 1 0 0 1 1.414 0l3.5 3.5a1 1 0 0 1 0 1.414l-3.5 3.5a1 1 0 1 1-1.414-1.414L12.086 13H4a1 1 0 1 1 0-2h8.086l-1.793-1.793a1 1 0 0 1 0-1.414Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 360 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M14.3 23v-1.1a1 1 0 0 1 2 0V23a1 1 0 1 1-2 0Zm5.243-3.457a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM4.788 9.298a1 1 0 0 1 1.424 1.404l-.742.752-.004.005a5.003 5.003 0 1 0 7.075 7.075l.005-.004.752-.742a1 1 0 0 1 1.404 1.424l-.747.736a7.003 7.003 0 1 1-9.904-9.904l.737-.746ZM23 14.3a1 1 0 0 1 0 2h-1.1a1 1 0 1 1 0-2H23ZM10.044 4.05a7.005 7.005 0 0 1 9.905 9.906h0l-.737.746a1 1 0 0 1-1.424-1.404l.742-.752.004-.005a5.003 5.003 0 1 0-7.075-7.075l-.005.004-.752.742a1 1 0 0 1-1.404-1.424l.746-.737ZM2.1 7.7a1 1 0 1 1 0 2H1a1 1 0 0 1 0-2h1.1Zm-.157-5.757a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM7.7 2.1V1a1 1 0 1 1 2 0v1.1a1 1 0 0 1-2 0Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 807 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M3 16.8V7.2c0-.544-.001-1.011.03-1.395.033-.395.104-.789.297-1.167a3 3 0 0 1 1.31-1.31c.379-.193.772-.265 1.168-.297C6.188 2.999 6.657 3 7.2 3H11a1 1 0 1 1 0 2H7.2c-.576 0-.949 0-1.232.023-.272.022-.373.06-.422.085a1 1 0 0 0-.437.437c-.025.05-.062.15-.085.422C5.001 6.251 5 6.623 5 7.2v9.6c0 .577.001.95.024 1.232.023.272.06.373.085.422a1 1 0 0 0 .437.437c.05.025.15.063.422.085.283.023.656.024 1.232.024h9.6c.576 0 .949-.001 1.232-.024.272-.022.373-.06.422-.085a1 1 0 0 0 .437-.437c.025-.049.062-.15.085-.422.023-.283.024-.655.024-1.232V13a1 1 0 1 1 2 0v3.8c0 .543.001 1.011-.03 1.395-.033.395-.104.788-.297 1.167a3 3 0 0 1-1.31 1.311c-.379.193-.772.264-1.168.296-.383.031-.852.031-1.395.031H7.2c-.543 0-1.012 0-1.395-.031-.396-.032-.789-.103-1.167-.296a3 3 0 0 1-1.31-1.311c-.194-.379-.265-.772-.298-1.167C3 17.81 3 17.343 3 16.8M16.629 2.957a3 3 0 0 1 4.242 0l.172.171a3 3 0 0 1 0 4.243L13 15.414a2 2 0 0 1-1.414.586H9a1 1 0 0 1-1-1v-2.586A2 2 0 0 1 8.586 11zM10 14h1.586l8.043-8.043a1 1 0 0 0 0-1.414l-.172-.172a1 1 0 0 0-1.414 0L10 12.414z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z"/></svg>
|
||||||
|
After Width: | Height: | Size: 502 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1zm5 2a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1"/></svg>
|
||||||
|
After Width: | Height: | Size: 376 B |
@@ -2,7 +2,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover">
|
||||||
<meta name="referrer" content="origin-when-cross-origin">
|
<meta name="referrer" content="origin-when-cross-origin">
|
||||||
<!--
|
<!--
|
||||||
Preconnect to essential domains
|
Preconnect to essential domains
|
||||||
|
|||||||
@@ -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
|
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too
|
||||||
* nit-picky
|
* nit-picky
|
||||||
*/
|
*/
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
'@typescript-eslint/ban-ts-comment': 'off',
|
'@typescript-eslint/ban-ts-comment': 'off',
|
||||||
'@typescript-eslint/no-empty-object-type': 'off',
|
'@typescript-eslint/no-empty-object-type': 'off',
|
||||||
'@typescript-eslint/no-unsafe-function-type': 'off',
|
'@typescript-eslint/no-unsafe-function-type': 'off',
|
||||||
|
|||||||
+7
-3
@@ -61,9 +61,13 @@ jest.mock('expo-media-library', () => ({
|
|||||||
usePermissions: jest.fn(() => [true]),
|
usePermissions: jest.fn(() => [true]),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
jest.mock('lande', () => ({
|
jest.mock('@bsky.app/expo-guess-language', () => ({
|
||||||
__esModule: true, // this property makes it work
|
guessLanguageSync: jest
|
||||||
default: jest.fn().mockReturnValue([['eng']]),
|
.fn()
|
||||||
|
.mockReturnValue([{language: 'en', confidence: 1}]),
|
||||||
|
guessLanguageAsync: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{language: 'en', confidence: 1}]),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
jest.mock('sentry-expo', () => ({
|
jest.mock('sentry-expo', () => ({
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
<string>com.apple.usernotifications.service</string>
|
<string>com.apple.usernotifications.service</string>
|
||||||
<key>NSExtensionPrincipalClass</key>
|
<key>NSExtensionPrincipalClass</key>
|
||||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>IntentsSupported</key>
|
||||||
|
<array>
|
||||||
|
<string>INSendMessageIntent</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>MainAppScheme</key>
|
<key>MainAppScheme</key>
|
||||||
<string>bluesky</string>
|
<string>bluesky</string>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import UserNotifications
|
import UserNotifications
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Intents
|
||||||
|
|
||||||
let APP_GROUP = "group.app.bsky"
|
let APP_GROUP = "group.app.bsky"
|
||||||
typealias ContentHandler = (UNNotificationContent) -> Void
|
typealias ContentHandler = (UNNotificationContent) -> Void
|
||||||
@@ -40,17 +41,18 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.bestAttempt = bestAttempt
|
self.bestAttempt = bestAttempt
|
||||||
if reason == "chat-message" {
|
|
||||||
|
if reason == "chat-message" || reason == "chat-reaction" {
|
||||||
mutateWithChatMessage(bestAttempt)
|
mutateWithChatMessage(bestAttempt)
|
||||||
|
let finalContent = createCommunicationNotification(
|
||||||
|
from: bestAttempt,
|
||||||
|
userInfo: request.content.userInfo
|
||||||
|
)
|
||||||
|
contentHandler(finalContent)
|
||||||
} else {
|
} else {
|
||||||
mutateWithBadge(bestAttempt)
|
mutateWithBadge(bestAttempt)
|
||||||
|
contentHandler(bestAttempt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any image downloading (or other network tasks) should be handled at the end
|
|
||||||
// of this block. Otherwise, if there is a timeout and serviceExtensionTimeWillExpire
|
|
||||||
// gets called, we might not have all the needed mutations completed in time.
|
|
||||||
|
|
||||||
contentHandler(bestAttempt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func serviceExtensionTimeWillExpire() {
|
override func serviceExtensionTimeWillExpire() {
|
||||||
@@ -61,6 +63,81 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
contentHandler(bestAttempt)
|
contentHandler(bestAttempt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Communication Notification
|
||||||
|
|
||||||
|
func createCommunicationNotification(
|
||||||
|
from content: UNMutableNotificationContent,
|
||||||
|
userInfo: [AnyHashable: Any]
|
||||||
|
) -> UNNotificationContent {
|
||||||
|
let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown"
|
||||||
|
let convoId = userInfo["convoId"] as? String
|
||||||
|
var avatarImage: INImage? = nil
|
||||||
|
if let avatarUrlString = userInfo["senderAvatarUrl"] as? String {
|
||||||
|
avatarImage = downloadAvatarImage(from: avatarUrlString)
|
||||||
|
}
|
||||||
|
|
||||||
|
let senderHandleValue = userInfo["senderHandle"] as? String
|
||||||
|
let senderHandle = INPersonHandle(value: senderHandleValue, type: .unknown)
|
||||||
|
let sender = INPerson(
|
||||||
|
personHandle: senderHandle,
|
||||||
|
nameComponents: nil,
|
||||||
|
displayName: senderDisplayName,
|
||||||
|
image: avatarImage,
|
||||||
|
contactIdentifier: nil,
|
||||||
|
customIdentifier: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let intent = INSendMessageIntent(
|
||||||
|
recipients: nil,
|
||||||
|
outgoingMessageType: .outgoingMessageText,
|
||||||
|
content: content.body,
|
||||||
|
speakableGroupName: nil,
|
||||||
|
conversationIdentifier: convoId,
|
||||||
|
serviceName: nil,
|
||||||
|
sender: sender,
|
||||||
|
attachments: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let interaction = INInteraction(intent: intent, response: nil)
|
||||||
|
interaction.direction = .incoming
|
||||||
|
interaction.donate(completion: nil)
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try content.updating(from: intent)
|
||||||
|
} catch {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadAvatarImage(from urlString: String) -> INImage? {
|
||||||
|
let thumbnailUrlString = urlString.replacingOccurrences(
|
||||||
|
of: "/img/avatar/",
|
||||||
|
with: "/img/avatar_thumbnail/"
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let url = URL(string: thumbnailUrlString) else { return nil }
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.timeoutInterval = 5
|
||||||
|
|
||||||
|
var imageData: Data? = nil
|
||||||
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
|
|
||||||
|
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
||||||
|
if let data = data,
|
||||||
|
let httpResponse = response as? HTTPURLResponse,
|
||||||
|
httpResponse.statusCode == 200 {
|
||||||
|
imageData = data
|
||||||
|
}
|
||||||
|
semaphore.signal()
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
|
semaphore.wait()
|
||||||
|
|
||||||
|
guard let data = imageData else { return nil }
|
||||||
|
return INImage(imageData: data)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Mutations
|
// MARK: Mutations
|
||||||
|
|
||||||
func mutateWithBadge(_ content: UNMutableNotificationContent) {
|
func mutateWithBadge(_ content: UNMutableNotificationContent) {
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ class BackgroundNotificationHandler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (remoteMessage.data["reason"] == "chat-message") {
|
if (remoteMessage.data["reason"] == "chat-message" || remoteMessage.data["reason"] == "chat-reaction") {
|
||||||
mutateWithChatMessage(remoteMessage)
|
mutateWithChatMessage(remoteMessage)
|
||||||
} else {
|
} else {
|
||||||
mutateWithOtherReason(remoteMessage)
|
mutateWithOtherReason(remoteMessage)
|
||||||
|
|||||||
+6
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bsky.app",
|
"name": "bsky.app",
|
||||||
"version": "1.121.0",
|
"version": "1.122.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"lint": "eslint --cache --quiet src",
|
"lint": "eslint --cache --quiet src",
|
||||||
"lint-native": "swiftlint ./modules && ktlint ./modules",
|
"lint-native": "swiftlint ./modules && ktlint ./modules",
|
||||||
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
|
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
|
||||||
"typecheck": "tsc --project ./tsconfig.check.json",
|
"typecheck": "tsgo --project ./tsconfig.check.json",
|
||||||
"e2e:mock-server": "cd dev-env && yarn start",
|
"e2e:mock-server": "cd dev-env && yarn start",
|
||||||
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
|
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
|
||||||
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
|
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
|
||||||
@@ -81,10 +81,12 @@
|
|||||||
"icons:optimize": "svgo -f ./assets/icons"
|
"icons:optimize": "svgo -f ./assets/icons"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.19.9",
|
"@atproto/api": "^0.19.11",
|
||||||
|
"@atproto/syntax": "0.5.2",
|
||||||
"@bitdrift/react-native": "^0.6.8",
|
"@bitdrift/react-native": "^0.6.8",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@bsky.app/alf": "^0.1.7",
|
"@bsky.app/alf": "^0.1.7",
|
||||||
|
"@bsky.app/expo-guess-language": "^0.2.8",
|
||||||
"@bsky.app/expo-image-crop-tool": "^0.5.0",
|
"@bsky.app/expo-image-crop-tool": "^0.5.0",
|
||||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
||||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||||
@@ -261,6 +263,7 @@
|
|||||||
"@types/psl": "^1.1.1",
|
"@types/psl": "^1.1.1",
|
||||||
"@types/react": "^19.1.12",
|
"@types/react": "^19.1.12",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@typescript/native-preview": "^7.0.0-dev.20260428.1",
|
||||||
"babel-jest": "^29.7.0",
|
"babel-jest": "^29.7.0",
|
||||||
"babel-plugin-module-resolver": "^5.0.2",
|
"babel-plugin-module-resolver": "^5.0.2",
|
||||||
"babel-plugin-react-compiler": "^19.1.0-rc.3",
|
"babel-plugin-react-compiler": "^19.1.0-rc.3",
|
||||||
|
|||||||
+2
-3
@@ -1,4 +1,4 @@
|
|||||||
import {type TextStyle} from 'react-native'
|
import {type FontVariant, type TextStyle} from 'react-native'
|
||||||
|
|
||||||
import {IS_ANDROID, IS_WEB} from '#/env'
|
import {IS_ANDROID, IS_WEB} from '#/env'
|
||||||
import {type Device, device} from '#/storage'
|
import {type Device, device} from '#/storage'
|
||||||
@@ -81,10 +81,9 @@ export function applyFonts(style: TextStyle, fontFamily: 'system' | 'theme') {
|
|||||||
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant}
|
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant}
|
||||||
*/
|
*/
|
||||||
if (IS_WEB) {
|
if (IS_WEB) {
|
||||||
// @ts-expect-error - web supports 'unicode' as a valid value for fontVariant
|
|
||||||
style.fontVariant = (style.fontVariant || []).concat(
|
style.fontVariant = (style.fontVariant || []).concat(
|
||||||
'no-contextual',
|
'no-contextual',
|
||||||
'unicode',
|
'unicode' as FontVariant, // web supports 'unicode' as a valid value for fontVariant
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
style.fontVariant = (style.fontVariant || []).concat('no-contextual')
|
style.fontVariant = (style.fontVariant || []).concat('no-contextual')
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ export enum Features {
|
|||||||
ImageUploadsHighResolution = 'image_uploads:high_resolution',
|
ImageUploadsHighResolution = 'image_uploads:high_resolution',
|
||||||
ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled',
|
ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled',
|
||||||
GroupChatsEnable = 'group_chats:enable',
|
GroupChatsEnable = 'group_chats:enable',
|
||||||
|
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
|
||||||
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
||||||
|
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||||
KlipyGifProviderEnable = 'klipy_gif_provider:enable',
|
KlipyGifProviderEnable = 'klipy_gif_provider:enable',
|
||||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||||
|
|
||||||
AATest = 'aa-test',
|
AATest = 'aa-test',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -487,6 +487,7 @@ export type Events = {
|
|||||||
| 'SeeMoreSuggestedUsers'
|
| 'SeeMoreSuggestedUsers'
|
||||||
| 'ProgressGuide'
|
| 'ProgressGuide'
|
||||||
location: 'Card' | 'Profile' | 'FollowAll'
|
location: 'Card' | 'Profile' | 'FollowAll'
|
||||||
|
recSource?: 'Search'
|
||||||
recId?: number | string
|
recId?: number | string
|
||||||
position: number
|
position: number
|
||||||
suggestedDid: string
|
suggestedDid: string
|
||||||
@@ -514,6 +515,7 @@ export type Events = {
|
|||||||
| 'Onboarding'
|
| 'Onboarding'
|
||||||
| 'SeeMoreSuggestedUsers'
|
| 'SeeMoreSuggestedUsers'
|
||||||
| 'ProgressGuide'
|
| 'ProgressGuide'
|
||||||
|
recSource?: 'Search'
|
||||||
recId?: number | string
|
recId?: number | string
|
||||||
position: number
|
position: number
|
||||||
suggestedDid: string
|
suggestedDid: string
|
||||||
@@ -554,7 +556,11 @@ export type Events = {
|
|||||||
| 'FindContacts'
|
| 'FindContacts'
|
||||||
}
|
}
|
||||||
'chat:create': {
|
'chat:create': {
|
||||||
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
logContext:
|
||||||
|
| 'ProfileHeader'
|
||||||
|
| 'NewChatDialog'
|
||||||
|
| 'SendViaChatDialog'
|
||||||
|
| 'ConvoSettings'
|
||||||
}
|
}
|
||||||
'chat:open': {
|
'chat:open': {
|
||||||
logContext:
|
logContext:
|
||||||
@@ -562,6 +568,7 @@ export type Events = {
|
|||||||
| 'NewChatDialog'
|
| 'NewChatDialog'
|
||||||
| 'ChatsList'
|
| 'ChatsList'
|
||||||
| 'SendViaChatDialog'
|
| 'SendViaChatDialog'
|
||||||
|
| 'ConvoSettings'
|
||||||
}
|
}
|
||||||
'groupchat:create': {
|
'groupchat:create': {
|
||||||
logContext: 'NewChatDialog'
|
logContext: 'NewChatDialog'
|
||||||
@@ -798,6 +805,100 @@ export type Events = {
|
|||||||
*/
|
*/
|
||||||
resultSourceLanguage: string
|
resultSourceLanguage: string
|
||||||
}
|
}
|
||||||
|
'composer:language:suggestLanguage': {
|
||||||
|
os: Platform['OS']
|
||||||
|
/**
|
||||||
|
* The language we detected and suggested to the user as an override for the
|
||||||
|
* expected target language.
|
||||||
|
*/
|
||||||
|
suggestedLanguage: string | undefined
|
||||||
|
/**
|
||||||
|
* This is the user's current composer languages, which are always defined.
|
||||||
|
*/
|
||||||
|
currentTargetLanguages: string[]
|
||||||
|
/**
|
||||||
|
* The length of the text being translated. We assume shorter texts are
|
||||||
|
* more likely to have inaccurate translations.
|
||||||
|
*/
|
||||||
|
textLength: number
|
||||||
|
}
|
||||||
|
'composer:language:acceptSuggestion': {
|
||||||
|
os: Platform['OS']
|
||||||
|
/**
|
||||||
|
* The language we detected and suggested to the user as an override for the
|
||||||
|
* expected target language.
|
||||||
|
*/
|
||||||
|
suggestedLanguage: string | undefined
|
||||||
|
/**
|
||||||
|
* This is the user's current composer languages, which are always defined.
|
||||||
|
*/
|
||||||
|
currentTargetLanguages: string[]
|
||||||
|
/**
|
||||||
|
* The length of the text being translated. We assume shorter texts are
|
||||||
|
* more likely to have inaccurate translations.
|
||||||
|
*/
|
||||||
|
textLength: number
|
||||||
|
}
|
||||||
|
'composer:language:declineSuggestion': {
|
||||||
|
os: Platform['OS']
|
||||||
|
/**
|
||||||
|
* The language we detected and suggested to the user as an override for the
|
||||||
|
* expected target language.
|
||||||
|
*/
|
||||||
|
suggestedLanguage: string | undefined
|
||||||
|
/**
|
||||||
|
* This is the user's current composer languages, which are always defined.
|
||||||
|
*/
|
||||||
|
currentTargetLanguages: string[]
|
||||||
|
/**
|
||||||
|
* The length of the text being translated. We assume shorter texts are
|
||||||
|
* more likely to have inaccurate translations.
|
||||||
|
*/
|
||||||
|
textLength: number
|
||||||
|
}
|
||||||
|
'composer:language:replyNudgeAccept': {
|
||||||
|
/**
|
||||||
|
* The language of the post the user is replying to.
|
||||||
|
*/
|
||||||
|
replyToLanguage: string
|
||||||
|
/**
|
||||||
|
* This is the user's current composer languages, which are always defined.
|
||||||
|
*/
|
||||||
|
currentTargetLanguages: string[]
|
||||||
|
}
|
||||||
|
'composer:language:replyNudgeDecline': {
|
||||||
|
/**
|
||||||
|
* The language of the post the user is replying to.
|
||||||
|
*/
|
||||||
|
replyToLanguage: string
|
||||||
|
/**
|
||||||
|
* This is the user's current composer languages, which are always defined.
|
||||||
|
*/
|
||||||
|
currentTargetLanguages: string[]
|
||||||
|
}
|
||||||
|
'composer:language:nudgeUser': {
|
||||||
|
os: Platform['OS']
|
||||||
|
/**
|
||||||
|
* The language we detected and suggested to the user as an override for the
|
||||||
|
* expected target language.
|
||||||
|
*/
|
||||||
|
suggestedLanguage: string | undefined
|
||||||
|
/**
|
||||||
|
* This is the user's current composer languages, which are always defined.
|
||||||
|
*/
|
||||||
|
currentTargetLanguages: string[]
|
||||||
|
/**
|
||||||
|
* The length of the text being translated. We assume shorter texts are
|
||||||
|
* more likely to have inaccurate translations.
|
||||||
|
*/
|
||||||
|
textLength: number
|
||||||
|
}
|
||||||
|
'composer:language:langSelectorPressed': {
|
||||||
|
/**
|
||||||
|
* If the user was nudged by our language detection to update their language
|
||||||
|
*/
|
||||||
|
wasNudged: boolean
|
||||||
|
}
|
||||||
|
|
||||||
'postMenu:openMuteWordsDialog': {
|
'postMenu:openMuteWordsDialog': {
|
||||||
uri: string
|
uri: string
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {useCallback} from 'react'
|
import {useCallback, useMemo} from 'react'
|
||||||
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
||||||
import {keepPreviousData, useQuery} from '@tanstack/react-query'
|
import {keepPreviousData, useQuery} from '@tanstack/react-query'
|
||||||
|
|
||||||
@@ -90,24 +90,34 @@ export function useAutocomplete({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showSearchFallback && q) {
|
|
||||||
results.unshift({
|
|
||||||
key: `search-${q}`,
|
|
||||||
type: 'search' as const,
|
|
||||||
value: q,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
},
|
},
|
||||||
[q, showSearchFallback, moderationOpts],
|
[q, moderationOpts],
|
||||||
),
|
),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
if (!query.data) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [...query.data]
|
||||||
|
|
||||||
|
if (showSearchFallback && q) {
|
||||||
|
results.unshift({
|
||||||
|
key: `search-${q}`,
|
||||||
|
type: 'search' as const,
|
||||||
|
value: q,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}, [query.data, showSearchFallback, q])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
query: q,
|
query: q,
|
||||||
items: query.data || [],
|
items,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import {useCallback, useEffect} from 'react'
|
import {useEffect} from 'react'
|
||||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
interpolate,
|
|
||||||
type SharedValue,
|
type SharedValue,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
@@ -16,36 +15,32 @@ import {atoms as a, useTheme} from '#/alf'
|
|||||||
import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
|
import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
|
||||||
import type * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
|
type Layout = {
|
||||||
|
size: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
zIndex?: number
|
||||||
|
border?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
animate?: boolean
|
animate?: boolean
|
||||||
profiles: bsky.profile.AnyProfileView[]
|
profiles: bsky.profile.AnyProfileView[]
|
||||||
size?: 'small' | 'medium' | 'large' | number
|
size?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AvatarBubbles({
|
export function AvatarBubbles({
|
||||||
animate = false,
|
animate = false,
|
||||||
profiles: allProfiles,
|
profiles: allProfiles,
|
||||||
size = 'large',
|
size = 120,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const profiles = allProfiles.filter(p => p.did !== currentAccount?.did)
|
const profiles =
|
||||||
const containerSize =
|
allProfiles.length > 2
|
||||||
typeof size === 'number'
|
? allProfiles.filter(p => p.did !== currentAccount?.did)
|
||||||
? size
|
: allProfiles
|
||||||
: size === 'small'
|
const scale = size / 120
|
||||||
? 40
|
const marginOffset = size < 120 ? -2 : 0
|
||||||
: size === 'medium'
|
|
||||||
? 56
|
|
||||||
: 120
|
|
||||||
const scale =
|
|
||||||
typeof size === 'number'
|
|
||||||
? size / 120
|
|
||||||
: size === 'small'
|
|
||||||
? 40 / 120
|
|
||||||
: size === 'medium'
|
|
||||||
? 56 / 120
|
|
||||||
: 1
|
|
||||||
const marginOffset = size === 'small' || size === 'medium' ? -2 : 0
|
|
||||||
|
|
||||||
const initialValue = animate ? 0 : 1
|
const initialValue = animate ? 0 : 1
|
||||||
const p0 = useSharedValue(initialValue)
|
const p0 = useSharedValue(initialValue)
|
||||||
@@ -53,130 +48,50 @@ export function AvatarBubbles({
|
|||||||
const p2 = useSharedValue(initialValue)
|
const p2 = useSharedValue(initialValue)
|
||||||
const p3 = useSharedValue(initialValue)
|
const p3 = useSharedValue(initialValue)
|
||||||
|
|
||||||
const animateScale = (p: Animated.SharedValue<number>, index: number) => {
|
|
||||||
p.set(0)
|
|
||||||
p.set(() =>
|
|
||||||
withDelay(
|
|
||||||
500 + index * 100,
|
|
||||||
withTiming(1, {
|
|
||||||
duration: 250,
|
|
||||||
easing: Easing.out(Easing.back(1.75)),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const playScaleAnimation = useCallback(() => {
|
|
||||||
animateScale(p0, 0)
|
|
||||||
animateScale(p1, 1)
|
|
||||||
animateScale(p2, 2)
|
|
||||||
animateScale(p3, 3)
|
|
||||||
}, [p0, p1, p2, p3])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!animate) return
|
if (!animate) return
|
||||||
playScaleAnimation()
|
const animateBubble = (p: SharedValue<number>, i: number) => {
|
||||||
}, [animate, playScaleAnimation])
|
p.set(0)
|
||||||
|
p.set(() =>
|
||||||
|
withDelay(
|
||||||
|
500 + i * 100,
|
||||||
|
withTiming(1, {
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.back(1.75)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
animateBubble(p0, 0)
|
||||||
|
animateBubble(p1, 1)
|
||||||
|
animateBubble(p2, 2)
|
||||||
|
animateBubble(p3, 3)
|
||||||
|
}, [animate, p0, p1, p2, p3])
|
||||||
|
|
||||||
let avatars = (
|
const scales = [p0, p1, p2, p3]
|
||||||
<>
|
const layouts = getLayouts(profiles.length)
|
||||||
<AvatarBubble
|
|
||||||
profile={profiles[0] ?? allProfiles[0]}
|
|
||||||
scale={p0}
|
|
||||||
size={76}
|
|
||||||
x={-2}
|
|
||||||
y={-2}
|
|
||||||
style={[a.z_20]}
|
|
||||||
includeProfileBorder
|
|
||||||
/>
|
|
||||||
<AvatarBubble
|
|
||||||
profile={profiles[1]}
|
|
||||||
scale={p1}
|
|
||||||
size={76}
|
|
||||||
x={42}
|
|
||||||
y={42}
|
|
||||||
style={[a.z_10]}
|
|
||||||
includeProfileBorder
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (profiles.length === 3) {
|
|
||||||
avatars = (
|
|
||||||
<>
|
|
||||||
<AvatarBubble
|
|
||||||
profile={profiles[0]}
|
|
||||||
scale={p0}
|
|
||||||
size={68}
|
|
||||||
x={-2}
|
|
||||||
y={-2}
|
|
||||||
/>
|
|
||||||
<AvatarBubble
|
|
||||||
profile={profiles[1]}
|
|
||||||
scale={p1}
|
|
||||||
size={56}
|
|
||||||
x={38}
|
|
||||||
y={62}
|
|
||||||
/>
|
|
||||||
<AvatarBubble
|
|
||||||
profile={profiles[2]}
|
|
||||||
scale={p2}
|
|
||||||
size={46}
|
|
||||||
x={71}
|
|
||||||
y={18}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profiles.length >= 4) {
|
|
||||||
avatars = (
|
|
||||||
<>
|
|
||||||
<AvatarBubble
|
|
||||||
profile={profiles[0]}
|
|
||||||
scale={p0}
|
|
||||||
size={68}
|
|
||||||
x={-2}
|
|
||||||
y={-2}
|
|
||||||
/>
|
|
||||||
<AvatarBubble
|
|
||||||
profile={profiles[1]}
|
|
||||||
scale={p1}
|
|
||||||
size={56}
|
|
||||||
x={60}
|
|
||||||
y={49}
|
|
||||||
/>
|
|
||||||
<AvatarBubble
|
|
||||||
profile={profiles[2]}
|
|
||||||
scale={p2}
|
|
||||||
size={42}
|
|
||||||
x={14}
|
|
||||||
y={74}
|
|
||||||
/>
|
|
||||||
<AvatarBubble profile={profiles[3]} scale={p3} size={32} x={72} y={9} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View style={[a.p_2xs, {height: size, width: size}]}>
|
||||||
style={[
|
|
||||||
a.p_2xs,
|
|
||||||
{
|
|
||||||
height: containerSize,
|
|
||||||
width: containerSize,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={{
|
||||||
{
|
marginTop: marginOffset,
|
||||||
marginTop: marginOffset,
|
marginLeft: marginOffset,
|
||||||
marginLeft: marginOffset,
|
transform: [{scale}],
|
||||||
transform: [{scale}],
|
transformOrigin: 'top left',
|
||||||
transformOrigin: 'top left',
|
}}>
|
||||||
},
|
{layouts.map((layout, i) => (
|
||||||
]}>
|
<AvatarBubble
|
||||||
{avatars}
|
key={i}
|
||||||
|
profile={profiles[i]}
|
||||||
|
scale={scales[i]}
|
||||||
|
size={layout.size}
|
||||||
|
x={layout.x}
|
||||||
|
y={layout.y}
|
||||||
|
zIndex={layout.zIndex}
|
||||||
|
includeProfileBorder={layout.border}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)
|
)
|
||||||
@@ -186,27 +101,23 @@ function AvatarBubble({
|
|||||||
profile,
|
profile,
|
||||||
scale,
|
scale,
|
||||||
size,
|
size,
|
||||||
style,
|
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
|
zIndex,
|
||||||
includeProfileBorder,
|
includeProfileBorder,
|
||||||
}: {
|
}: {
|
||||||
profile?: bsky.profile.AnyProfileView
|
profile?: bsky.profile.AnyProfileView
|
||||||
scale: SharedValue<number>
|
scale: SharedValue<number>
|
||||||
size: number
|
size: number
|
||||||
style?: StyleProp<ViewStyle>
|
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
|
zIndex?: number
|
||||||
includeProfileBorder?: boolean
|
includeProfileBorder?: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
transform: [
|
transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}],
|
||||||
{translateX: x},
|
|
||||||
{translateY: y},
|
|
||||||
{scale: interpolate(scale.get(), [0, 1], [0, 1])},
|
|
||||||
],
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -219,11 +130,17 @@ function AvatarBubble({
|
|||||||
borderColor: t.atoms.text_inverted.color,
|
borderColor: t.atoms.text_inverted.color,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
},
|
},
|
||||||
style,
|
zIndex != null && {zIndex},
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
]}>
|
]}>
|
||||||
{profile ? (
|
{profile ? (
|
||||||
<Avatar profile={profile} size={size} />
|
<UserAvatar
|
||||||
|
avatar={profile.avatar}
|
||||||
|
size={size}
|
||||||
|
type="user"
|
||||||
|
hideLiveBadge
|
||||||
|
noBorder
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AvatarPlaceholder size={size} />
|
<AvatarPlaceholder size={size} />
|
||||||
)}
|
)}
|
||||||
@@ -231,25 +148,7 @@ function AvatarBubble({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Avatar({
|
function AvatarPlaceholder({size}: {size: number}) {
|
||||||
profile,
|
|
||||||
size = 76,
|
|
||||||
}: {
|
|
||||||
profile: bsky.profile.AnyProfileView
|
|
||||||
size?: number
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<UserAvatar
|
|
||||||
avatar={profile.avatar}
|
|
||||||
size={size}
|
|
||||||
type="user"
|
|
||||||
hideLiveBadge
|
|
||||||
noBorder
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AvatarPlaceholder({size = 76}: {size?: number}) {
|
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -259,10 +158,7 @@ function AvatarPlaceholder({size = 76}: {size?: number}) {
|
|||||||
a.justify_center,
|
a.justify_center,
|
||||||
a.rounded_full,
|
a.rounded_full,
|
||||||
t.atoms.bg_contrast_200,
|
t.atoms.bg_contrast_200,
|
||||||
{
|
{width: size, height: size},
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
},
|
|
||||||
]}>
|
]}>
|
||||||
<PersonIcon
|
<PersonIcon
|
||||||
width={size * 0.5}
|
width={size * 0.5}
|
||||||
@@ -272,3 +168,25 @@ function AvatarPlaceholder({size = 76}: {size?: number}) {
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLayouts(count: number): Layout[] {
|
||||||
|
if (count === 3) {
|
||||||
|
return [
|
||||||
|
{size: 68, x: -2, y: -2},
|
||||||
|
{size: 56, x: 38, y: 62},
|
||||||
|
{size: 46, x: 71, y: 18},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (count >= 4) {
|
||||||
|
return [
|
||||||
|
{size: 68, x: -2, y: -2},
|
||||||
|
{size: 56, x: 60, y: 49},
|
||||||
|
{size: 42, x: 14, y: 74},
|
||||||
|
{size: 32, x: 72, y: 9},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{size: 76, x: -2, y: -2, zIndex: 20, border: true},
|
||||||
|
{size: 76, x: 42, y: 42, zIndex: 10, border: true},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ export type ButtonState = {
|
|||||||
focused: boolean
|
focused: boolean
|
||||||
pressed: boolean
|
pressed: boolean
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
|
/**
|
||||||
|
* Alias for hovered || focused || pressed
|
||||||
|
*/
|
||||||
|
interacting: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ButtonContext = VariantProps & ButtonState
|
export type ButtonContext = VariantProps & ButtonState
|
||||||
@@ -120,6 +124,7 @@ const Context = createContext<VariantProps & ButtonState>({
|
|||||||
focused: false,
|
focused: false,
|
||||||
pressed: false,
|
pressed: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
interacting: false,
|
||||||
})
|
})
|
||||||
Context.displayName = 'ButtonContext'
|
Context.displayName = 'ButtonContext'
|
||||||
|
|
||||||
@@ -536,6 +541,7 @@ export const Button = forwardRef<View, ButtonProps>(
|
|||||||
const context = useMemo<ButtonContext>(
|
const context = useMemo<ButtonContext>(
|
||||||
() => ({
|
() => ({
|
||||||
...state,
|
...state,
|
||||||
|
interacting: state.hovered || state.focused || state.pressed,
|
||||||
variant,
|
variant,
|
||||||
color,
|
color,
|
||||||
size,
|
size,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import {HITSLOP_10} from '#/lib/constants'
|
|||||||
import {useHaptics} from '#/lib/haptics'
|
import {useHaptics} from '#/lib/haptics'
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {atoms as a, platform, tokens, useTheme} from '#/alf'
|
import {atoms as a, flatten, platform, tokens, useTheme} from '#/alf'
|
||||||
import {
|
import {
|
||||||
Context,
|
Context,
|
||||||
ItemContext,
|
ItemContext,
|
||||||
@@ -527,7 +527,8 @@ export function AuxiliaryView({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const menuContext = useMemo(() => ({align}), [align])
|
const xOffset = (flatten(style)?.marginLeft as number) ?? 0
|
||||||
|
const menuContext = useMemo(() => ({align, xOffset}), [align, xOffset])
|
||||||
|
|
||||||
const onLayout = useCallback(() => {
|
const onLayout = useCallback(() => {
|
||||||
if (!measurement) return
|
if (!measurement) return
|
||||||
@@ -655,7 +656,8 @@ export function Outer({
|
|||||||
[context.measurement, frame.height, insets, translationSV],
|
[context.measurement, frame.height, insets, translationSV],
|
||||||
)
|
)
|
||||||
|
|
||||||
const menuContext = useMemo(() => ({align}), [align])
|
const xOffset = (flatten(style)?.marginLeft as number) ?? 0
|
||||||
|
const menuContext = useMemo(() => ({align, xOffset}), [align, xOffset])
|
||||||
|
|
||||||
if (!context.isOpen || !context.measurement) return null
|
if (!context.isOpen || !context.measurement) return null
|
||||||
|
|
||||||
@@ -763,7 +765,7 @@ export function Item({
|
|||||||
onOut: onPressOut,
|
onOut: onPressOut,
|
||||||
} = useInteractionState()
|
} = useInteractionState()
|
||||||
const id = useId()
|
const id = useId()
|
||||||
const {align} = useContextMenuMenuContext()
|
const {align, xOffset: menuXOffset} = useContextMenuMenuContext()
|
||||||
|
|
||||||
const {close, measurement, registerHoverable} = context
|
const {close, measurement, registerHoverable} = context
|
||||||
|
|
||||||
@@ -779,8 +781,8 @@ export function Item({
|
|||||||
const xOffset = position
|
const xOffset = position
|
||||||
? position.x
|
? position.x
|
||||||
: align === 'left'
|
: align === 'left'
|
||||||
? measurement.x
|
? measurement.x + menuXOffset
|
||||||
: measurement.x + measurement.width - layout.width
|
: measurement.x + measurement.width - layout.width - menuXOffset
|
||||||
|
|
||||||
registerHoverable(
|
registerHoverable(
|
||||||
id,
|
id,
|
||||||
@@ -796,7 +798,16 @@ export function Item({
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[id, measurement, registerHoverable, close, onPress, align, position],
|
[
|
||||||
|
id,
|
||||||
|
measurement,
|
||||||
|
registerHoverable,
|
||||||
|
close,
|
||||||
|
onPress,
|
||||||
|
align,
|
||||||
|
menuXOffset,
|
||||||
|
position,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
const itemContext = useMemo(
|
const itemContext = useMemo(
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export type ContextType = {
|
|||||||
|
|
||||||
export type MenuContextType = {
|
export type MenuContextType = {
|
||||||
align: 'left' | 'right'
|
align: 'left' | 'right'
|
||||||
|
xOffset: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ItemContextType = {
|
export type ItemContextType = {
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ export function SuggestedFollowsHome() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ProfileGrid
|
<ProfileGrid
|
||||||
|
recId={data?.recId}
|
||||||
isSuggestionsLoading={isLoading}
|
isSuggestionsLoading={isLoading}
|
||||||
profiles={filteredProfiles}
|
profiles={filteredProfiles}
|
||||||
totalProfileCount={allProfiles.length}
|
totalProfileCount={allProfiles.length}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import {forwardRef, memo, useContext, useMemo} from 'react'
|
import {forwardRef, memo, useContext, useMemo} from 'react'
|
||||||
import {StyleSheet, View, type ViewProps, type ViewStyle} from 'react-native'
|
import {
|
||||||
import {type StyleProp} from 'react-native'
|
type StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
type ViewProps,
|
||||||
|
type ViewStyle,
|
||||||
|
} from 'react-native'
|
||||||
import {
|
import {
|
||||||
KeyboardAwareScrollView,
|
KeyboardAwareScrollView,
|
||||||
type KeyboardAwareScrollViewProps,
|
type KeyboardAwareScrollViewProps,
|
||||||
@@ -11,6 +16,7 @@ import Animated, {
|
|||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
|
|
||||||
|
import {useEnableMinimalShellModeForScreen} from '#/state/shell'
|
||||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||||
import {
|
import {
|
||||||
atoms as a,
|
atoms as a,
|
||||||
@@ -30,6 +36,7 @@ export * as Header from '#/components/Layout/Header'
|
|||||||
export type ScreenProps = React.ComponentProps<typeof View> & {
|
export type ScreenProps = React.ComponentProps<typeof View> & {
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
noInsetTop?: boolean
|
noInsetTop?: boolean
|
||||||
|
minimalShell?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,9 +45,13 @@ export type ScreenProps = React.ComponentProps<typeof View> & {
|
|||||||
export const Screen = memo(function Screen({
|
export const Screen = memo(function Screen({
|
||||||
style,
|
style,
|
||||||
noInsetTop,
|
noInsetTop,
|
||||||
|
minimalShell = false,
|
||||||
...props
|
...props
|
||||||
}: ScreenProps) {
|
}: ScreenProps) {
|
||||||
const {top} = useSafeAreaInsets()
|
const {top} = useSafeAreaInsets()
|
||||||
|
|
||||||
|
useEnableMinimalShellModeForScreen({enabled: minimalShell})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{IS_WEB && <WebCenterBorders />}
|
{IS_WEB && <WebCenterBorders />}
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export function AvatarPlaceholder({size = 40}: {size?: number}) {
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.rounded_full,
|
a.rounded_full,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_50,
|
||||||
{
|
{
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
@@ -348,7 +348,7 @@ export function NameAndHandlePlaceholder() {
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.rounded_xs,
|
a.rounded_xs,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_50,
|
||||||
{
|
{
|
||||||
width: '60%',
|
width: '60%',
|
||||||
height: 14,
|
height: 14,
|
||||||
@@ -359,7 +359,7 @@ export function NameAndHandlePlaceholder() {
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.rounded_xs,
|
a.rounded_xs,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_50,
|
||||||
{
|
{
|
||||||
width: '40%',
|
width: '40%',
|
||||||
height: 10,
|
height: 10,
|
||||||
@@ -377,7 +377,7 @@ export function NamePlaceholder({style}: ViewStyleProp) {
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.rounded_xs,
|
a.rounded_xs,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_50,
|
||||||
{
|
{
|
||||||
width: '60%',
|
width: '60%',
|
||||||
height: 14,
|
height: 14,
|
||||||
@@ -439,7 +439,7 @@ export function DescriptionPlaceholder({
|
|||||||
style={[
|
style={[
|
||||||
a.rounded_xs,
|
a.rounded_xs,
|
||||||
a.w_full,
|
a.w_full,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_50,
|
||||||
{height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
|
{height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -600,7 +600,7 @@ export function FollowButtonPlaceholder({style}: ViewStyleProp) {
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.rounded_sm,
|
a.rounded_sm,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_50,
|
||||||
a.w_full,
|
a.w_full,
|
||||||
{
|
{
|
||||||
height: 33,
|
height: 33,
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
|||||||
moderationOpts={moderationOpts!}
|
moderationOpts={moderationOpts!}
|
||||||
noBorder={index === 0}
|
noBorder={index === 0}
|
||||||
position={index}
|
position={index}
|
||||||
|
recSource={hasSearchText ? 'Search' : undefined}
|
||||||
recId={recIdForLogging}
|
recId={recIdForLogging}
|
||||||
isGuide={isGuide}
|
isGuide={isGuide}
|
||||||
/>
|
/>
|
||||||
@@ -264,7 +265,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[moderationOpts, recIdForLogging, isGuide],
|
[moderationOpts, hasSearchText, recIdForLogging, isGuide],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Track seen profiles
|
// Track seen profiles
|
||||||
@@ -286,6 +287,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
|||||||
)
|
)
|
||||||
ax.metric('suggestedUser:seen', {
|
ax.metric('suggestedUser:seen', {
|
||||||
logContext: isGuide ? 'ProgressGuide' : 'SeeMoreSuggestedUsers',
|
logContext: isGuide ? 'ProgressGuide' : 'SeeMoreSuggestedUsers',
|
||||||
|
recSource: hasSearchText ? 'Search' : undefined,
|
||||||
recId: recIdForLogging,
|
recId: recIdForLogging,
|
||||||
position: position !== -1 ? position : 0,
|
position: position !== -1 ? position : 0,
|
||||||
suggestedDid: item.profile.did,
|
suggestedDid: item.profile.did,
|
||||||
@@ -548,6 +550,7 @@ let FollowProfileCard = ({
|
|||||||
moderationOpts,
|
moderationOpts,
|
||||||
noBorder,
|
noBorder,
|
||||||
position,
|
position,
|
||||||
|
recSource,
|
||||||
recId,
|
recId,
|
||||||
isGuide,
|
isGuide,
|
||||||
}: {
|
}: {
|
||||||
@@ -555,6 +558,7 @@ let FollowProfileCard = ({
|
|||||||
moderationOpts: ModerationOpts
|
moderationOpts: ModerationOpts
|
||||||
noBorder?: boolean
|
noBorder?: boolean
|
||||||
position: number
|
position: number
|
||||||
|
recSource?: 'Search'
|
||||||
recId?: string
|
recId?: string
|
||||||
isGuide: boolean
|
isGuide: boolean
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
@@ -564,6 +568,7 @@ let FollowProfileCard = ({
|
|||||||
moderationOpts={moderationOpts}
|
moderationOpts={moderationOpts}
|
||||||
noBorder={noBorder}
|
noBorder={noBorder}
|
||||||
position={position}
|
position={position}
|
||||||
|
recSource={recSource}
|
||||||
recId={recId}
|
recId={recId}
|
||||||
isGuide={isGuide}
|
isGuide={isGuide}
|
||||||
/>
|
/>
|
||||||
@@ -577,6 +582,7 @@ function FollowProfileCardInner({
|
|||||||
onFollow,
|
onFollow,
|
||||||
noBorder,
|
noBorder,
|
||||||
position,
|
position,
|
||||||
|
recSource,
|
||||||
recId,
|
recId,
|
||||||
isGuide,
|
isGuide,
|
||||||
}: {
|
}: {
|
||||||
@@ -585,6 +591,7 @@ function FollowProfileCardInner({
|
|||||||
onFollow?: () => void
|
onFollow?: () => void
|
||||||
noBorder?: boolean
|
noBorder?: boolean
|
||||||
position: number
|
position: number
|
||||||
|
recSource?: 'Search'
|
||||||
recId?: string
|
recId?: string
|
||||||
isGuide: boolean
|
isGuide: boolean
|
||||||
}) {
|
}) {
|
||||||
@@ -625,6 +632,7 @@ function FollowProfileCardInner({
|
|||||||
? 'ProgressGuide'
|
? 'ProgressGuide'
|
||||||
: 'SeeMoreSuggestedUsers',
|
: 'SeeMoreSuggestedUsers',
|
||||||
location: 'Card',
|
location: 'Card',
|
||||||
|
recSource,
|
||||||
recId,
|
recId,
|
||||||
position,
|
position,
|
||||||
suggestedDid: profile.did,
|
suggestedDid: profile.did,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {type GestureResponderEvent, View} from 'react-native'
|
|||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {atoms as a, useTheme, type ViewStyleProp, web} from '#/alf'
|
import {atoms as a, type TextStyleProp, useTheme, web} from '#/alf'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
type ButtonColor,
|
type ButtonColor,
|
||||||
@@ -34,6 +34,8 @@ export function Outer({
|
|||||||
control,
|
control,
|
||||||
testID,
|
testID,
|
||||||
nativeOptions,
|
nativeOptions,
|
||||||
|
webOptions,
|
||||||
|
onClose,
|
||||||
}: React.PropsWithChildren<{
|
}: React.PropsWithChildren<{
|
||||||
control: Dialog.DialogControlProps
|
control: Dialog.DialogControlProps
|
||||||
testID?: string
|
testID?: string
|
||||||
@@ -41,6 +43,13 @@ export function Outer({
|
|||||||
* Native-specific options for the prompt. Extends `BottomSheetViewProps`
|
* Native-specific options for the prompt. Extends `BottomSheetViewProps`
|
||||||
*/
|
*/
|
||||||
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
|
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
|
||||||
|
/**
|
||||||
|
* Web-specific options for the prompt
|
||||||
|
*/
|
||||||
|
webOptions?: {
|
||||||
|
onBackgroundPress?: (e: GestureResponderEvent) => void
|
||||||
|
}
|
||||||
|
onClose?: () => void
|
||||||
}>) {
|
}>) {
|
||||||
const titleId = useId()
|
const titleId = useId()
|
||||||
const descriptionId = useId()
|
const descriptionId = useId()
|
||||||
@@ -54,7 +63,8 @@ export function Outer({
|
|||||||
<Dialog.Outer
|
<Dialog.Outer
|
||||||
control={control}
|
control={control}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
webOptions={{alignCenter: true}}
|
onClose={onClose}
|
||||||
|
webOptions={{alignCenter: true, ...webOptions}}
|
||||||
nativeOptions={{preventExpansion: true, ...nativeOptions}}>
|
nativeOptions={{preventExpansion: true, ...nativeOptions}}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
<Context.Provider value={context}>
|
<Context.Provider value={context}>
|
||||||
@@ -72,7 +82,7 @@ export function Outer({
|
|||||||
export function TitleText({
|
export function TitleText({
|
||||||
children,
|
children,
|
||||||
style,
|
style,
|
||||||
}: React.PropsWithChildren<ViewStyleProp>) {
|
}: React.PropsWithChildren<TextStyleProp>) {
|
||||||
const {titleId} = useContext(Context)
|
const {titleId} = useContext(Context)
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
@@ -93,14 +103,21 @@ export function TitleText({
|
|||||||
export function DescriptionText({
|
export function DescriptionText({
|
||||||
children,
|
children,
|
||||||
selectable,
|
selectable,
|
||||||
}: React.PropsWithChildren<{selectable?: boolean}>) {
|
style,
|
||||||
|
}: React.PropsWithChildren<{selectable?: boolean} & TextStyleProp>) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {descriptionId} = useContext(Context)
|
const {descriptionId} = useContext(Context)
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
nativeID={descriptionId}
|
nativeID={descriptionId}
|
||||||
selectable={selectable}
|
selectable={selectable}
|
||||||
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high, a.pb_lg]}>
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
a.pb_lg,
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -512,7 +512,7 @@ function ExistingChatCard({
|
|||||||
]}>
|
]}>
|
||||||
<ProfileCard.Header>
|
<ProfileCard.Header>
|
||||||
{convo.kind === 'group' ? (
|
{convo.kind === 'group' ? (
|
||||||
<AvatarBubbles profiles={convo.members} size="small" />
|
<AvatarBubbles profiles={convo.members} size={40} />
|
||||||
) : (
|
) : (
|
||||||
<ProfileCard.Avatar
|
<ProfileCard.Avatar
|
||||||
profile={convo.primaryMember}
|
profile={convo.primaryMember}
|
||||||
|
|||||||
@@ -4,23 +4,29 @@ import {useLingui} from '@lingui/react/macro'
|
|||||||
|
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
|
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
export function ActionsWrapper({
|
export function ActionsWrapper({
|
||||||
message,
|
message,
|
||||||
isFromSelf,
|
isFromSelf,
|
||||||
|
senderProfile,
|
||||||
children,
|
children,
|
||||||
onTap,
|
onTap,
|
||||||
}: {
|
}: {
|
||||||
message: ChatBskyConvoDefs.MessageView
|
message: ChatBskyConvoDefs.MessageView
|
||||||
hasReactions?: boolean
|
hasReactions?: boolean
|
||||||
isFromSelf: boolean
|
isFromSelf: boolean
|
||||||
|
senderProfile?: bsky.profile.AnyProfileView
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
onTap?: () => void
|
onTap?: () => void
|
||||||
}) {
|
}) {
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageContextMenu message={message} onTap={onTap}>
|
<MessageContextMenu
|
||||||
|
message={message}
|
||||||
|
senderProfile={senderProfile}
|
||||||
|
onTap={onTap}>
|
||||||
{trigger =>
|
{trigger =>
|
||||||
// will always be true, since this file is platform split
|
// will always be true, since this file is platform split
|
||||||
trigger.IS_NATIVE && (
|
trigger.IS_NATIVE && (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
|
|||||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
|
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
|
||||||
import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
|
import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
|
||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
import {EmojiReactionPicker} from './EmojiReactionPicker'
|
import {EmojiReactionPicker} from './EmojiReactionPicker'
|
||||||
import {hasReachedReactionLimit} from './util'
|
import {hasReachedReactionLimit} from './util'
|
||||||
|
|
||||||
@@ -17,12 +18,14 @@ export function ActionsWrapper({
|
|||||||
message,
|
message,
|
||||||
hasReactions,
|
hasReactions,
|
||||||
isFromSelf,
|
isFromSelf,
|
||||||
|
senderProfile,
|
||||||
children,
|
children,
|
||||||
onTap,
|
onTap,
|
||||||
}: {
|
}: {
|
||||||
message: ChatBskyConvoDefs.MessageView
|
message: ChatBskyConvoDefs.MessageView
|
||||||
hasReactions?: boolean
|
hasReactions?: boolean
|
||||||
isFromSelf: boolean
|
isFromSelf: boolean
|
||||||
|
senderProfile?: bsky.profile.AnyProfileView
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
onTap?: () => void
|
onTap?: () => void
|
||||||
}) {
|
}) {
|
||||||
@@ -114,7 +117,7 @@ export function ActionsWrapper({
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</EmojiReactionPicker>
|
</EmojiReactionPicker>
|
||||||
<MessageContextMenu message={message}>
|
<MessageContextMenu message={message} senderProfile={senderProfile}>
|
||||||
{({props, state, IS_NATIVE, control}) => {
|
{({props, state, IS_NATIVE, control}) => {
|
||||||
// always false, file is platform split
|
// always false, file is platform split
|
||||||
if (IS_NATIVE) return null
|
if (IS_NATIVE) return null
|
||||||
|
|||||||
@@ -11,16 +11,18 @@ import {Trans, useLingui} from '@lingui/react/macro'
|
|||||||
|
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||||
|
import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members'
|
||||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {type ListMethods} from '#/view/com/util/List'
|
import {type ListMethods} from '#/view/com/util/List'
|
||||||
import {android, atoms as a, native, useTheme, web} from '#/alf'
|
import {android, atoms as a, native, useTheme, web} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {canBeMessaged} from '#/components/dms/util'
|
import {canBeMessaged, type ConvoWithDetails} from '#/components/dms/util'
|
||||||
import * as Toggle from '#/components/forms/Toggle'
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
|
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||||
import type * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
@@ -54,12 +56,12 @@ type PlaceholderItem = {
|
|||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ErrorItem = {
|
type LoadingItem = {
|
||||||
type: 'error'
|
type: 'loading'
|
||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | ErrorItem
|
type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | LoadingItem
|
||||||
|
|
||||||
export type State = {
|
export type State = {
|
||||||
groupChatDids: string[]
|
groupChatDids: string[]
|
||||||
@@ -98,30 +100,46 @@ function reducer(state: State, action: Action): State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AddMembersFlow({
|
export function AddMembersFlow({
|
||||||
|
convo,
|
||||||
title,
|
title,
|
||||||
onAddMembers,
|
onAddMembers,
|
||||||
}: {
|
}: {
|
||||||
|
convo: Extract<ConvoWithDetails, {kind: 'group'}>
|
||||||
title: string
|
title: string
|
||||||
onAddMembers: (dids: string[]) => void
|
onAddMembers: (
|
||||||
|
dids: string[],
|
||||||
|
profiles: bsky.profile.AnyProfileView[],
|
||||||
|
) => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
const control = Dialog.useDialogContext()
|
const control = Dialog.useDialogContext()
|
||||||
|
|
||||||
const [headerHeight, setHeaderHeight] = useState(0)
|
const [headerHeight, setHeaderHeight] = useState(0)
|
||||||
const [footerHeight, setFooterHeight] = useState(0)
|
const [footerHeight, setFooterHeight] = useState(0)
|
||||||
const listRef = useRef<ListMethods>(null)
|
|
||||||
const {currentAccount} = useSession()
|
|
||||||
const inputRef = useRef<TextInput>(null)
|
|
||||||
|
|
||||||
const [searchText, setSearchText] = useState('')
|
const [searchText, setSearchText] = useState('')
|
||||||
|
|
||||||
|
const listRef = useRef<ListMethods>(null)
|
||||||
|
const inputRef = useRef<TextInput>(null)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: results,
|
data: autocompleteResults,
|
||||||
isError,
|
isError,
|
||||||
isFetching,
|
isFetching: isAutocompleteFetching,
|
||||||
} = useActorAutocompleteQuery(searchText, true, 12)
|
} = useActorAutocompleteQuery(searchText, true, 12)
|
||||||
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
|
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
|
||||||
|
const {data: memberListData = [], isPending: isMemberListPending} =
|
||||||
|
useListConvoMembersQuery({
|
||||||
|
convoId: convo.view.id,
|
||||||
|
placeholderData: convo.members,
|
||||||
|
})
|
||||||
|
const memberDidSet = useMemo(
|
||||||
|
() => new Set(memberListData.map(profile => profile.did)),
|
||||||
|
[memberListData],
|
||||||
|
)
|
||||||
|
|
||||||
const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, {
|
const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, {
|
||||||
groupChatDids: [],
|
groupChatDids: [],
|
||||||
@@ -142,8 +160,13 @@ export function AddMembersFlow({
|
|||||||
[groupChatDids, groupChatProfiles],
|
[groupChatDids, groupChatProfiles],
|
||||||
)
|
)
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo<Item[]>(() => {
|
||||||
let _items: Item[] = []
|
if (isMemberListPending) {
|
||||||
|
// Still fetching chat member DIDs for filtering, so force the loading state.
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const _items: Item[] = []
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
_items.push({
|
_items.push({
|
||||||
@@ -152,9 +175,13 @@ export function AddMembersFlow({
|
|||||||
message: l`We’re having network issues, try again`,
|
message: l`We’re having network issues, try again`,
|
||||||
})
|
})
|
||||||
} else if (searchText.length) {
|
} else if (searchText.length) {
|
||||||
if (results?.length) {
|
if (autocompleteResults?.length) {
|
||||||
for (const profile of results) {
|
for (const profile of autocompleteResults) {
|
||||||
if (profile.did === currentAccount?.did) continue
|
if (
|
||||||
|
profile.did === currentAccount?.did ||
|
||||||
|
memberDidSet.has(profile.did)
|
||||||
|
)
|
||||||
|
continue
|
||||||
_items.push({
|
_items.push({
|
||||||
type: 'profile',
|
type: 'profile',
|
||||||
key: profile.did,
|
key: profile.did,
|
||||||
@@ -162,18 +189,11 @@ export function AddMembersFlow({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
_items = _items.sort(item => {
|
_items.sort(item => {
|
||||||
return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1
|
return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const placeholders: Item[] = Array(10)
|
|
||||||
.fill(0)
|
|
||||||
.map((__, i) => ({
|
|
||||||
type: 'placeholder',
|
|
||||||
key: i + '',
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (follows) {
|
if (follows) {
|
||||||
for (const page of follows.pages) {
|
for (const page of follows.pages) {
|
||||||
for (const profile of page.follows) {
|
for (const profile of page.follows) {
|
||||||
@@ -185,11 +205,13 @@ export function AddMembersFlow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_items = _items.sort(item => {
|
_items.sort(item => {
|
||||||
return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1
|
return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
_items.push(...placeholders)
|
for (let i = 0; i < 10; i++) {
|
||||||
|
_items.push({type: 'placeholder', key: i + ''})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,20 +223,39 @@ export function AddMembersFlow({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return _items
|
if (searchText && isAutocompleteFetching && _items.length > 0) {
|
||||||
}, [isError, searchText, l, results, currentAccount?.did, follows])
|
// Stale results are still showing while autocomplete refetches -
|
||||||
|
// append an inline indicator so the user sees that work is happening.
|
||||||
|
_items.push({type: 'loading', key: 'loading'})
|
||||||
|
} else if (
|
||||||
|
searchText &&
|
||||||
|
!isAutocompleteFetching &&
|
||||||
|
!_items.length &&
|
||||||
|
!isError
|
||||||
|
) {
|
||||||
|
_items.push({type: 'empty', key: 'empty', message: l`No results`})
|
||||||
|
}
|
||||||
|
|
||||||
if (searchText && !isFetching && !items.length && !isError) {
|
return _items
|
||||||
items.push({type: 'empty', key: 'empty', message: l`No results`})
|
}, [
|
||||||
}
|
autocompleteResults,
|
||||||
|
currentAccount?.did,
|
||||||
|
follows,
|
||||||
|
isAutocompleteFetching,
|
||||||
|
isError,
|
||||||
|
isMemberListPending,
|
||||||
|
l,
|
||||||
|
memberDidSet,
|
||||||
|
searchText,
|
||||||
|
])
|
||||||
|
|
||||||
const handlePressBack = useCallback(() => {
|
const handlePressBack = useCallback(() => {
|
||||||
control.close()
|
control.close()
|
||||||
}, [control])
|
}, [control])
|
||||||
|
|
||||||
const handlePressAdd = useCallback(() => {
|
const handlePressAdd = useCallback(() => {
|
||||||
onAddMembers(groupChatDids)
|
onAddMembers(groupChatDids, groupChatProfiles)
|
||||||
}, [groupChatDids, onAddMembers])
|
}, [groupChatDids, groupChatProfiles, onAddMembers])
|
||||||
|
|
||||||
const renderItems = useCallback(
|
const renderItems = useCallback(
|
||||||
({item}: {item: Item}) => {
|
({item}: {item: Item}) => {
|
||||||
@@ -234,6 +275,13 @@ export function AddMembersFlow({
|
|||||||
case 'placeholder': {
|
case 'placeholder': {
|
||||||
return <ProfileCardSkeleton key={item.key} />
|
return <ProfileCardSkeleton key={item.key} />
|
||||||
}
|
}
|
||||||
|
case 'loading': {
|
||||||
|
return (
|
||||||
|
<View style={[a.px_lg, a.py_xl, a.align_center]}>
|
||||||
|
<Loader size="lg" />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
case 'empty': {
|
case 'empty': {
|
||||||
return <EmptyMemberList key={item.key} message={item.message} />
|
return <EmptyMemberList key={item.key} message={item.message} />
|
||||||
}
|
}
|
||||||
@@ -427,12 +475,24 @@ export function AddMembersFlow({
|
|||||||
renderItem={renderItems}
|
renderItem={renderItems}
|
||||||
ListHeaderComponent={listHeader}
|
ListHeaderComponent={listHeader}
|
||||||
stickyHeaderIndices={[0]}
|
stickyHeaderIndices={[0]}
|
||||||
|
ListEmptyComponent={
|
||||||
|
isMemberListPending || isAutocompleteFetching ? (
|
||||||
|
<View style={[a.flex_1, a.align_center, a.justify_center]}>
|
||||||
|
<Loader size="xl" />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
keyExtractor={(item: Item) => item.key}
|
keyExtractor={(item: Item) => item.key}
|
||||||
style={[
|
style={[
|
||||||
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
|
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
|
||||||
native({height: '100%'}),
|
native({height: '100%'}),
|
||||||
]}
|
]}
|
||||||
webInnerContentContainerStyle={[a.py_0, {paddingBottom: footerHeight}]}
|
contentContainerStyle={items.length === 0 ? {flexGrow: 1} : undefined}
|
||||||
|
webInnerContentContainerStyle={[
|
||||||
|
a.py_0,
|
||||||
|
{paddingBottom: footerHeight},
|
||||||
|
items.length === 0 && {flexGrow: 1},
|
||||||
|
]}
|
||||||
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
||||||
scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}}
|
scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}}
|
||||||
keyboardDismissMode="on-drag"
|
keyboardDismissMode="on-drag"
|
||||||
@@ -448,7 +508,6 @@ export function AddMembersFlow({
|
|||||||
onPress={handlePressBack}>
|
onPress={handlePressBack}>
|
||||||
<ButtonIcon icon={ArrowLeftIcon} size="md" />
|
<ButtonIcon icon={ArrowLeftIcon} size="md" />
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
{' '}
|
|
||||||
<Trans>Back</Trans>
|
<Trans>Back</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {Trans, useLingui} from '@lingui/react/macro'
|
|||||||
import {subDays} from 'date-fns'
|
import {subDays} from 'date-fns'
|
||||||
|
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Text} from '../Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {localDateString} from './util'
|
import {localDateString} from './util'
|
||||||
|
|
||||||
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
@@ -56,12 +56,11 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.w_full, a.my_sm]}>
|
<View style={[a.w_full, a.mt_md]}>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.text_xs,
|
a.text_xs,
|
||||||
a.text_center,
|
a.text_center,
|
||||||
t.atoms.bg,
|
|
||||||
t.atoms.text_contrast_medium,
|
t.atoms.text_contrast_medium,
|
||||||
a.px_md,
|
a.px_md,
|
||||||
]}>
|
]}>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function EmojiReactionPicker({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||||
const {measurement, close} = useContextMenuContext()
|
const {measurement, close} = useContextMenuContext()
|
||||||
const {align} = useContextMenuMenuContext()
|
const {align, xOffset} = useContextMenuMenuContext()
|
||||||
const [layout, setLayout] = useState({width: 0, height: 0})
|
const [layout, setLayout] = useState({width: 0, height: 0})
|
||||||
const {width: screenWidth} = useWindowDimensions()
|
const {width: screenWidth} = useWindowDimensions()
|
||||||
|
|
||||||
@@ -44,12 +44,15 @@ export function EmojiReactionPicker({
|
|||||||
|
|
||||||
const position = useMemo(() => {
|
const position = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
x: align === 'left' ? 12 : screenWidth - layout.width - 12,
|
x:
|
||||||
|
align === 'left'
|
||||||
|
? (measurement?.x ?? 0) + xOffset
|
||||||
|
: (measurement?.x ?? 0) + (measurement?.width ?? 0) - layout.width,
|
||||||
y: (measurement?.y ?? 0) - tokens.space.xs - layout.height,
|
y: (measurement?.y ?? 0) - tokens.space.xs - layout.height,
|
||||||
height: layout.height,
|
height: layout.height,
|
||||||
width: layout.width,
|
width: layout.width,
|
||||||
}
|
}
|
||||||
}, [measurement, align, screenWidth, layout])
|
}, [measurement, align, xOffset, screenWidth, layout])
|
||||||
|
|
||||||
const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
|
const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
|
||||||
|
|
||||||
|
|||||||
@@ -25,15 +25,18 @@ import {usePromptControl} from '#/components/Prompt'
|
|||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_NATIVE} from '#/env'
|
import {IS_NATIVE} from '#/env'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
import {EmojiReactionPicker} from './EmojiReactionPicker'
|
import {EmojiReactionPicker} from './EmojiReactionPicker'
|
||||||
import {hasReachedReactionLimit} from './util'
|
import {hasReachedReactionLimit} from './util'
|
||||||
|
|
||||||
export let MessageContextMenu = ({
|
export let MessageContextMenu = ({
|
||||||
message,
|
message,
|
||||||
|
senderProfile,
|
||||||
children,
|
children,
|
||||||
onTap,
|
onTap,
|
||||||
}: {
|
}: {
|
||||||
message: ChatBskyConvoDefs.MessageView
|
message: ChatBskyConvoDefs.MessageView
|
||||||
|
senderProfile?: bsky.profile.AnyProfileView
|
||||||
children: TriggerProps['children']
|
children: TriggerProps['children']
|
||||||
onTap?: () => void
|
onTap?: () => void
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
@@ -110,9 +113,7 @@ export let MessageContextMenu = ({
|
|||||||
[l, convo, message, currentAccount?.did],
|
[l, convo, message, currentAccount?.did],
|
||||||
)
|
)
|
||||||
|
|
||||||
const sender = convo.convo.members.find(
|
const sender = senderProfile
|
||||||
member => member.did === message.sender.did,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -183,7 +184,7 @@ export let MessageContextMenu = ({
|
|||||||
control={reportControl}
|
control={reportControl}
|
||||||
subject={{
|
subject={{
|
||||||
view: 'message',
|
view: 'message',
|
||||||
convoId: convo.convo.id,
|
convoId: convo.convo.view.id,
|
||||||
message,
|
message,
|
||||||
}}
|
}}
|
||||||
onAfterSubmit={() => {
|
onAfterSubmit={() => {
|
||||||
@@ -197,7 +198,7 @@ export let MessageContextMenu = ({
|
|||||||
control={blockOrDeleteControl}
|
control={blockOrDeleteControl}
|
||||||
currentScreen="conversation"
|
currentScreen="conversation"
|
||||||
params={{
|
params={{
|
||||||
convoId: convo.convo.id,
|
convoId: convo.convo.view.id,
|
||||||
message,
|
message,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {memo, useCallback, useEffect, useMemo, useRef} from 'react'
|
import {memo, useEffect, useMemo, useRef} from 'react'
|
||||||
import {
|
import {
|
||||||
type GestureResponderEvent,
|
type GestureResponderEvent,
|
||||||
LayoutAnimation,
|
LayoutAnimation,
|
||||||
@@ -28,10 +28,8 @@ import {plural} from '@lingui/core/macro'
|
|||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
|
||||||
import {useConvoActive} from '#/state/messages/convo'
|
|
||||||
import {type ConvoItem} from '#/state/messages/convo/types'
|
import {type ConvoItem} from '#/state/messages/convo/types'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||||
@@ -44,7 +42,6 @@ import {InlineLinkText, Link} from '#/components/Link'
|
|||||||
import * as ProfileCard from '#/components/ProfileCard'
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import type * as bsky from '#/types/bsky'
|
|
||||||
import {DateDivider} from './DateDivider'
|
import {DateDivider} from './DateDivider'
|
||||||
import {useDateDividerToggle} from './DateDividerToggle'
|
import {useDateDividerToggle} from './DateDividerToggle'
|
||||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||||
@@ -59,7 +56,9 @@ const DISPLAY_NAME_INSET = 22
|
|||||||
const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
|
const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
|
||||||
const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
|
const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
function isWithinCluster({
|
const TAP_AND_DRAG_DELAY_MS = 100
|
||||||
|
|
||||||
|
function isWithinClusterBoundary({
|
||||||
isPending,
|
isPending,
|
||||||
adjacentMessage,
|
adjacentMessage,
|
||||||
isFromSameSender,
|
isFromSameSender,
|
||||||
@@ -92,62 +91,56 @@ function isWithinCluster({
|
|||||||
let MessageItem = ({
|
let MessageItem = ({
|
||||||
item,
|
item,
|
||||||
isGroupChat = false,
|
isGroupChat = false,
|
||||||
profile,
|
|
||||||
}: {
|
}: {
|
||||||
item: ConvoItem & {type: 'message' | 'pending-message'}
|
item: ConvoItem & {type: 'message' | 'pending-message'}
|
||||||
isGroupChat?: boolean
|
isGroupChat?: boolean
|
||||||
profile?: bsky.profile.AnyProfileView
|
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const {convo} = useConvoActive()
|
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const profile = item.relatedProfiles.get(item.message.sender.did)
|
||||||
|
|
||||||
const reactionsControl = useDialogControl()
|
const reactionsControl = useDialogControl()
|
||||||
const reactionTapRef = useRef(false)
|
const reactionTapRef = useRef(false)
|
||||||
|
|
||||||
const {message, nextMessage, prevMessage} = item
|
const {message, nextMessage, prevMessage} = item
|
||||||
const isPending = item.type === 'pending-message'
|
const isPending = item.type === 'pending-message'
|
||||||
|
|
||||||
const displayName = sanitizeDisplayName(
|
const displayName = profile ? createSanitizedDisplayName(profile) : null
|
||||||
profile?.displayName || sanitizeHandle(profile?.handle ?? ''),
|
|
||||||
)
|
|
||||||
|
|
||||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
const isFromSelf =
|
||||||
|
message.sender?.did != null && message.sender.did === currentAccount?.did
|
||||||
|
|
||||||
const prevIsMessage = ChatBskyConvoDefs.isMessageView(prevMessage)
|
const prevIsMessage = ChatBskyConvoDefs.isMessageView(prevMessage)
|
||||||
const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage)
|
const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage)
|
||||||
|
|
||||||
const isPrevFromSameSender =
|
const isPrevFromSameSender =
|
||||||
prevIsMessage && prevMessage.sender?.did === message.sender?.did
|
prevIsMessage &&
|
||||||
|
prevMessage.sender?.did === message.sender?.did &&
|
||||||
|
message.sender?.did != null
|
||||||
const isNextFromSameSender =
|
const isNextFromSameSender =
|
||||||
nextIsMessage && nextMessage.sender?.did === message.sender?.did
|
nextIsMessage &&
|
||||||
|
nextMessage.sender?.did === message.sender?.did &&
|
||||||
|
message.sender?.did != null
|
||||||
|
|
||||||
const isFirstInCluster = useMemo(
|
const isFirstInCluster = isWithinClusterBoundary({
|
||||||
() =>
|
isPending,
|
||||||
isWithinCluster({
|
adjacentMessage: prevMessage,
|
||||||
isPending,
|
isFromSameSender: isPrevFromSameSender,
|
||||||
adjacentMessage: prevMessage,
|
currentSentAt: message.sentAt,
|
||||||
isFromSameSender: isPrevFromSameSender,
|
direction: 'prev',
|
||||||
currentSentAt: message.sentAt,
|
})
|
||||||
direction: 'prev',
|
|
||||||
}),
|
|
||||||
[isPending, prevMessage, isPrevFromSameSender, message.sentAt],
|
|
||||||
)
|
|
||||||
|
|
||||||
const isLastInCluster = useMemo(
|
const isLastInCluster = isWithinClusterBoundary({
|
||||||
() =>
|
isPending,
|
||||||
isWithinCluster({
|
adjacentMessage: nextMessage,
|
||||||
isPending,
|
isFromSameSender: isNextFromSameSender,
|
||||||
adjacentMessage: nextMessage,
|
currentSentAt: message.sentAt,
|
||||||
isFromSameSender: isNextFromSameSender,
|
direction: 'next',
|
||||||
currentSentAt: message.sentAt,
|
})
|
||||||
direction: 'next',
|
|
||||||
}),
|
|
||||||
[isPending, nextMessage, isNextFromSameSender, message.sentAt],
|
|
||||||
)
|
|
||||||
|
|
||||||
const hasLargeGapFromPrev =
|
const hasLargeGapFromPrev =
|
||||||
!ChatBskyConvoDefs.isMessageView(prevMessage) ||
|
!ChatBskyConvoDefs.isMessageView(prevMessage) ||
|
||||||
@@ -159,7 +152,6 @@ let MessageItem = ({
|
|||||||
const isDateDividerToggled = isDividerToggled(message.id)
|
const isDateDividerToggled = isDividerToggled(message.id)
|
||||||
const isNextDateDividerToggled =
|
const isNextDateDividerToggled =
|
||||||
nextMessage != null && isDividerToggled(nextMessage.id)
|
nextMessage != null && isDividerToggled(nextMessage.id)
|
||||||
const showDateDivider = hasLargeGapFromPrev
|
|
||||||
|
|
||||||
const effectiveFirstInCluster = isFirstInCluster || isDateDividerToggled
|
const effectiveFirstInCluster = isFirstInCluster || isDateDividerToggled
|
||||||
const effectiveLastInCluster = isLastInCluster || isNextDateDividerToggled
|
const effectiveLastInCluster = isLastInCluster || isNextDateDividerToggled
|
||||||
@@ -168,18 +160,20 @@ let MessageItem = ({
|
|||||||
isInCluster && !effectiveFirstInCluster && !effectiveLastInCluster
|
isInCluster && !effectiveFirstInCluster && !effectiveLastInCluster
|
||||||
|
|
||||||
const hasReactions = message.reactions && message.reactions.length > 0
|
const hasReactions = message.reactions && message.reactions.length > 0
|
||||||
|
const prevHasReactions =
|
||||||
|
prevIsMessage && prevMessage.reactions && prevMessage.reactions.length > 0
|
||||||
const squaredBottomCorner =
|
const squaredBottomCorner =
|
||||||
!hasReactions &&
|
!hasReactions &&
|
||||||
isInCluster &&
|
isInCluster &&
|
||||||
(isInMiddleOfCluster || effectiveFirstInCluster)
|
(isInMiddleOfCluster || effectiveFirstInCluster)
|
||||||
const squaredTopCorner =
|
const squaredTopCorner =
|
||||||
isInCluster && (isInMiddleOfCluster || effectiveLastInCluster)
|
!prevHasReactions &&
|
||||||
|
isInCluster &&
|
||||||
|
(isInMiddleOfCluster || effectiveLastInCluster)
|
||||||
|
|
||||||
const pendingColor = t.palette.primary_300
|
const pendingColor = t.palette.primary_300
|
||||||
|
|
||||||
const rt = useMemo(() => {
|
const rt = new RichTextAPI({text: message.text, facets: message.facets})
|
||||||
return new RichTextAPI({text: message.text, facets: message.facets})
|
|
||||||
}, [message.text, message.facets])
|
|
||||||
|
|
||||||
const hasEmbedAndText =
|
const hasEmbedAndText =
|
||||||
AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0
|
AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0
|
||||||
@@ -219,27 +213,26 @@ let MessageItem = ({
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const avatar = profile ? (
|
const avatar =
|
||||||
<Link
|
profile && moderationOpts ? (
|
||||||
label={l`${sanitizeDisplayName(
|
<Link
|
||||||
profile.displayName || sanitizeHandle(profile.handle),
|
label={l`${createSanitizedDisplayName(profile)}’s avatar`}
|
||||||
)}’s avatar`}
|
accessibilityHint={l`Opens this profile`}
|
||||||
accessibilityHint={l`Opens this profile`}
|
to={makeProfileLink({
|
||||||
to={makeProfileLink({
|
did: profile.did,
|
||||||
did: profile.did,
|
handle: profile.handle,
|
||||||
handle: profile.handle,
|
})}
|
||||||
})}
|
onPress={() => unstableCacheProfileView(queryClient, profile)}>
|
||||||
onPress={() => unstableCacheProfileView(queryClient, profile)}>
|
<ProfileCard.Avatar
|
||||||
<ProfileCard.Avatar
|
profile={profile}
|
||||||
profile={profile}
|
size={AVATAR_SIZE}
|
||||||
size={AVATAR_SIZE}
|
moderationOpts={moderationOpts}
|
||||||
moderationOpts={moderationOpts!}
|
disabledPreview
|
||||||
disabledPreview
|
/>
|
||||||
/>
|
</Link>
|
||||||
</Link>
|
) : (
|
||||||
) : (
|
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
|
||||||
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
|
)
|
||||||
)
|
|
||||||
|
|
||||||
const groupedReactions = useMemo(() => {
|
const groupedReactions = useMemo(() => {
|
||||||
const reactions = message.reactions ?? []
|
const reactions = message.reactions ?? []
|
||||||
@@ -281,11 +274,9 @@ let MessageItem = ({
|
|||||||
return l`You reacted ${reaction.value}`
|
return l`You reacted ${reaction.value}`
|
||||||
} else {
|
} else {
|
||||||
const senderDid = reaction.sender.did
|
const senderDid = reaction.sender.did
|
||||||
const sender = convo.members.find(member => member.did === senderDid)
|
const memberSender = item.relatedProfiles.get(senderDid)
|
||||||
if (sender) {
|
if (memberSender) {
|
||||||
return l`${sanitizeDisplayName(
|
return l`${createSanitizedDisplayName(memberSender)} reacted ${reaction.value}`
|
||||||
sender.displayName || sender.handle,
|
|
||||||
)} reacted ${reaction.value}`
|
|
||||||
}
|
}
|
||||||
return l`Someone reacted ${reaction.value}`
|
return l`Someone reacted ${reaction.value}`
|
||||||
}
|
}
|
||||||
@@ -294,7 +285,13 @@ let MessageItem = ({
|
|||||||
one: '# person',
|
one: '# person',
|
||||||
other: '# people',
|
other: '# people',
|
||||||
})} reacted – ${groupedReactions.map(g => g.value).join(' ')}`
|
})} reacted – ${groupedReactions.map(g => g.value).join(' ')}`
|
||||||
}, [reactions, groupedReactions, currentAccount?.did, convo.members, l])
|
}, [
|
||||||
|
reactions,
|
||||||
|
groupedReactions,
|
||||||
|
currentAccount?.did,
|
||||||
|
item.relatedProfiles,
|
||||||
|
l,
|
||||||
|
])
|
||||||
|
|
||||||
const appliedReactions = (
|
const appliedReactions = (
|
||||||
<LayoutAnimationConfig skipEntering skipExiting>
|
<LayoutAnimationConfig skipEntering skipExiting>
|
||||||
@@ -337,14 +334,16 @@ let MessageItem = ({
|
|||||||
// Include a delay here to account for tap-and-drag before release.
|
// Include a delay here to account for tap-and-drag before release.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
reactionTapRef.current = false
|
reactionTapRef.current = false
|
||||||
}, 100)
|
}, TAP_AND_DRAG_DELAY_MS)
|
||||||
}}
|
}}
|
||||||
onPress={() => (isGroupChat ? reactionsControl.open() : undefined)}>
|
onPress={isGroupChat ? reactionsControl.open : undefined}>
|
||||||
{groupedReactions.map(group => (
|
{groupedReactions.map(group => (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={native(ZoomIn.springify(200).delay(400))}
|
entering={native(ZoomIn.springify(200).delay(400))}
|
||||||
exiting={
|
exiting={
|
||||||
groupedReactions.length > 1 && native(ZoomOut.delay(200))
|
groupedReactions.length > 1
|
||||||
|
? native(ZoomOut.delay(200))
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
layout={native(LinearTransition.delay(300))}
|
layout={native(LinearTransition.delay(300))}
|
||||||
key={group.value}
|
key={group.value}
|
||||||
@@ -377,7 +376,7 @@ let MessageItem = ({
|
|||||||
) : null}
|
) : null}
|
||||||
<ReactionsDialog
|
<ReactionsDialog
|
||||||
control={reactionsControl}
|
control={reactionsControl}
|
||||||
members={convo.members}
|
relatedProfiles={item.relatedProfiles}
|
||||||
message={message}
|
message={message}
|
||||||
reactions={message.reactions}
|
reactions={message.reactions}
|
||||||
groupedReactions={groupedReactions}
|
groupedReactions={groupedReactions}
|
||||||
@@ -393,13 +392,14 @@ let MessageItem = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(showDateDivider || isDateDividerToggled) && (
|
<LayoutAnimationConfig skipExiting skipEntering>
|
||||||
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
|
{(hasLargeGapFromPrev || isDateDividerToggled) && (
|
||||||
<DateDivider date={message.sentAt} />
|
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
|
||||||
</Animated.View>
|
<DateDivider date={message.sentAt} />
|
||||||
)}
|
</Animated.View>
|
||||||
<View
|
)}
|
||||||
style={[messageInset, isFirstInCluster && !showDateDivider && a.mt_sm]}>
|
</LayoutAnimationConfig>
|
||||||
|
<View style={[messageInset, effectiveFirstInCluster && a.mt_md]}>
|
||||||
<View style={[a.relative]}>
|
<View style={[a.relative]}>
|
||||||
{showAvatar ? (
|
{showAvatar ? (
|
||||||
<View
|
<View
|
||||||
@@ -419,7 +419,7 @@ let MessageItem = ({
|
|||||||
a.flex_grow,
|
a.flex_grow,
|
||||||
!isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE},
|
!isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE},
|
||||||
]}>
|
]}>
|
||||||
{showDisplayName ? (
|
{displayName && showDisplayName ? (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.text_xs,
|
a.text_xs,
|
||||||
@@ -437,6 +437,7 @@ let MessageItem = ({
|
|||||||
hasReactions={hasReactions}
|
hasReactions={hasReactions}
|
||||||
isFromSelf={isFromSelf}
|
isFromSelf={isFromSelf}
|
||||||
message={message}
|
message={message}
|
||||||
|
senderProfile={profile}
|
||||||
onTap={() => {
|
onTap={() => {
|
||||||
if (reactionTapRef.current) return
|
if (reactionTapRef.current) return
|
||||||
if (!hasLargeGapFromPrev) {
|
if (!hasLargeGapFromPrev) {
|
||||||
@@ -454,7 +455,6 @@ let MessageItem = ({
|
|||||||
...(isOnlyEmoji(message.text)
|
...(isOnlyEmoji(message.text)
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
a.rounded_md,
|
|
||||||
a.rounded_xl,
|
a.rounded_xl,
|
||||||
a.py_sm,
|
a.py_sm,
|
||||||
a.px_md,
|
a.px_md,
|
||||||
@@ -474,7 +474,23 @@ let MessageItem = ({
|
|||||||
]}>
|
]}>
|
||||||
<RichText
|
<RichText
|
||||||
value={rt}
|
value={rt}
|
||||||
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
isFromSelf && {color: t.palette.white},
|
||||||
|
// Emoji-only: add top leading to avoid clipping the
|
||||||
|
// glyph, then pull the bottom up by the same amount so
|
||||||
|
// the glyph bottom-aligns with the avatar instead of
|
||||||
|
// sitting above its line-box baseline.
|
||||||
|
isOnlyEmoji(message.text) && [
|
||||||
|
a.leading_tight,
|
||||||
|
// Visually align bottom of the emoji with the avatar
|
||||||
|
!isFromSelf &&
|
||||||
|
platform({
|
||||||
|
android: {marginTop: a.mt_2xs.marginTop},
|
||||||
|
default: {marginBottom: -a.mb_sm.marginBottom},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]}
|
||||||
interactiveStyle={a.underline}
|
interactiveStyle={a.underline}
|
||||||
enableTags
|
enableTags
|
||||||
emojiMultiplier={3}
|
emojiMultiplier={3}
|
||||||
@@ -517,16 +533,13 @@ let MessageItemMetadata = ({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
const handleRetry = useCallback(
|
const handleRetry = (e: GestureResponderEvent) => {
|
||||||
(e: GestureResponderEvent) => {
|
if (item.type === 'pending-message' && item.retry) {
|
||||||
if (item.type === 'pending-message' && item.retry) {
|
e.preventDefault()
|
||||||
e.preventDefault()
|
item.retry()
|
||||||
item.retry()
|
return false
|
||||||
return false
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[item],
|
|
||||||
)
|
|
||||||
|
|
||||||
const errorColor = t.palette.negative_400
|
const errorColor = t.palette.negative_400
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ let MessageItemEmbed = ({
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
!isFromSelf && a.ml_sm,
|
!isFromSelf && a.ml_sm,
|
||||||
t.atoms.bg,
|
|
||||||
native({
|
native({
|
||||||
flexBasis: 0,
|
flexBasis: 0,
|
||||||
width: Math.min(screen.width, 600) / 1.4,
|
width: Math.min(screen.width, 600) / 1.4,
|
||||||
|
|||||||
@@ -161,11 +161,13 @@ function GroupHeaderReady({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lockStatus = convo.details.lockStatus
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper
|
<Wrapper
|
||||||
heading={
|
heading={
|
||||||
<>
|
<>
|
||||||
<AvatarBubbles size="small" profiles={convo.members} />
|
<AvatarBubbles size={40} profiles={convo.members} />
|
||||||
<Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
|
<Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
|
||||||
{convo.details.name}
|
{convo.details.name}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -175,6 +177,7 @@ function GroupHeaderReady({
|
|||||||
settings={
|
settings={
|
||||||
<Button
|
<Button
|
||||||
label={l`Open group chat settings`}
|
label={l`Open group chat settings`}
|
||||||
|
disabled={lockStatus === 'locked-permanently'}
|
||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
shape="round"
|
shape="round"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import Animated from 'react-native-reanimated'
|
import Animated from 'react-native-reanimated'
|
||||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
import {type ChatBskyActorDefs, type ChatBskyConvoDefs} from '@atproto/api'
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {HITSLOP_10} from '#/lib/constants'
|
import {HITSLOP_10} from '#/lib/constants'
|
||||||
@@ -33,13 +33,13 @@ type Reaction = {
|
|||||||
|
|
||||||
export function ReactionsDialog({
|
export function ReactionsDialog({
|
||||||
control,
|
control,
|
||||||
members,
|
relatedProfiles,
|
||||||
message,
|
message,
|
||||||
reactions,
|
reactions,
|
||||||
groupedReactions,
|
groupedReactions,
|
||||||
}: {
|
}: {
|
||||||
control: Dialog.DialogControlProps
|
control: Dialog.DialogControlProps
|
||||||
members: bsky.profile.AnyProfileView[]
|
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||||
message: ChatBskyConvoDefs.MessageView
|
message: ChatBskyConvoDefs.MessageView
|
||||||
reactions?: ChatBskyConvoDefs.ReactionView[]
|
reactions?: ChatBskyConvoDefs.ReactionView[]
|
||||||
groupedReactions?: Reaction[]
|
groupedReactions?: Reaction[]
|
||||||
@@ -100,7 +100,7 @@ export function ReactionsDialog({
|
|||||||
return 0
|
return 0
|
||||||
})
|
})
|
||||||
.map(reaction => {
|
.map(reaction => {
|
||||||
const sender = members.find(m => m.did === reaction.sender.did)
|
const sender = relatedProfiles.get(reaction.sender.did)
|
||||||
if (!sender) return null
|
if (!sender) return null
|
||||||
return (
|
return (
|
||||||
<ReactionRow
|
<ReactionRow
|
||||||
|
|||||||
@@ -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} & (
|
export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & (
|
||||||
| {
|
| {
|
||||||
kind: 'group'
|
kind: 'group'
|
||||||
details: ChatBskyConvoDefs.GroupConvo
|
details: $Typed<ChatBskyConvoDefs.GroupConvo>
|
||||||
primaryMember: GroupConvoMember // the owner
|
primaryMember: GroupConvoMember // the owner
|
||||||
members: Array<GroupConvoMember>
|
members: Array<GroupConvoMember>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'direct'
|
kind: 'direct'
|
||||||
details: ChatBskyConvoDefs.DirectConvo
|
details: $Typed<ChatBskyConvoDefs.DirectConvo>
|
||||||
primaryMember: DirectConvoMember // the other user
|
primaryMember: DirectConvoMember // the other user
|
||||||
members: Array<DirectConvoMember>
|
members: Array<DirectConvoMember>
|
||||||
}
|
}
|
||||||
@@ -110,14 +110,16 @@ export function parseConvoView(
|
|||||||
owner = member as GroupConvoMember
|
owner = member as GroupConvoMember
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
logger.warn(
|
||||||
'Expected a GroupConvoMember, got an unknown kind of member',
|
'Expected a GroupConvoMember, got an unknown kind of member',
|
||||||
)
|
)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!owner) {
|
if (!owner) {
|
||||||
throw new Error('No owner found in group convo')
|
logger.warn('No owner found in group convo')
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -136,7 +138,8 @@ export function parseConvoView(
|
|||||||
const otherUser = convoView.members.find(m => m.did !== ownDid)
|
const otherUser = convoView.members.find(m => m.did !== ownDid)
|
||||||
|
|
||||||
if (!otherUser) {
|
if (!otherUser) {
|
||||||
throw new Error('No other user found in direct convo')
|
logger.warn('No other user found in direct convo')
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export type ItemProps = ViewStyleProp & {
|
|||||||
isInvalid?: boolean
|
isInvalid?: boolean
|
||||||
children: ((props: ItemState) => React.ReactNode) | React.ReactNode
|
children: ((props: ItemState) => React.ReactNode) | React.ReactNode
|
||||||
hitSlop?: PressableProps['hitSlop']
|
hitSlop?: PressableProps['hitSlop']
|
||||||
|
highlightRow?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useItemContext() {
|
export function useItemContext() {
|
||||||
@@ -160,8 +161,11 @@ export function Item({
|
|||||||
style,
|
style,
|
||||||
type = 'checkbox',
|
type = 'checkbox',
|
||||||
label,
|
label,
|
||||||
|
highlightRow,
|
||||||
...rest
|
...rest
|
||||||
}: ItemProps) {
|
}: ItemProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
values: selectedValues,
|
values: selectedValues,
|
||||||
type: groupType,
|
type: groupType,
|
||||||
@@ -207,6 +211,12 @@ export function Item({
|
|||||||
[name, selected, disabled, hovered, pressed, focused, isInvalid],
|
[name, selected, disabled, hovered, pressed, focused, isInvalid],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const highlightStyle = highlightRow
|
||||||
|
? selected
|
||||||
|
? [a.rounded_full, a.p_md, {backgroundColor: t.palette.primary_50}]
|
||||||
|
: [a.rounded_full, a.p_md]
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ItemContext.Provider value={state}>
|
<ItemContext.Provider value={state}>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -232,7 +242,7 @@ export function Item({
|
|||||||
onPressOut={onPressOut}
|
onPressOut={onPressOut}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
style={[a.flex_row, a.align_center, a.gap_sm, style]}>
|
style={[a.flex_row, a.align_center, a.gap_sm, highlightStyle, style]}>
|
||||||
{typeof children === 'function' ? children(state) : children}
|
{typeof children === 'function' ? children(state) : children}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</ItemContext.Provider>
|
</ItemContext.Provider>
|
||||||
|
|||||||
@@ -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({
|
export const ChainLink_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
path: 'M18.535 5.465a5.003 5.003 0 0 0-7.076 0l-.005.005-.752.742a1 1 0 1 1-1.404-1.424l.749-.74a7.003 7.003 0 0 1 9.904 9.905l-.002.003-.737.746a1 1 0 1 1-1.424-1.404l.747-.757a5.003 5.003 0 0 0 0-7.076ZM6.202 9.288a1 1 0 0 1 .01 1.414l-.747.757a5.003 5.003 0 1 0 7.076 7.076l.005-.005.752-.742a1 1 0 1 1 1.404 1.424l-.746.737-.003.002a7.003 7.003 0 0 1-9.904-9.904l.74-.75a1 1 0 0 1 1.413-.009Zm8.505.005a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414-1.414l4-4a1 1 0 0 1 1.414 0Z',
|
path: 'M18.535 5.465a5.003 5.003 0 0 0-7.076 0l-.005.005-.752.742a1 1 0 1 1-1.404-1.424l.749-.74a7.003 7.003 0 0 1 9.904 9.905l-.002.003-.737.746a1 1 0 1 1-1.424-1.404l.747-.757a5.003 5.003 0 0 0 0-7.076ZM6.202 9.288a1 1 0 0 1 .01 1.414l-.747.757a5.003 5.003 0 1 0 7.076 7.076l.005-.005.752-.742a1 1 0 1 1 1.404 1.424l-.746.737-.003.002a7.003 7.003 0 0 1-9.904-9.904l.74-.75a1 1 0 0 1 1.413-.009Zm8.505.005a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414-1.414l4-4a1 1 0 0 1 1.414 0Z',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const ChainLinkBroken_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M14.3 23v-1.1a1 1 0 0 1 2 0V23a1 1 0 1 1-2 0Zm5.243-3.457a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM4.788 9.298a1 1 0 0 1 1.424 1.404l-.742.752-.004.005a5.003 5.003 0 1 0 7.075 7.075l.005-.004.752-.742a1 1 0 0 1 1.404 1.424l-.747.736a7.003 7.003 0 1 1-9.904-9.904l.737-.746ZM23 14.3a1 1 0 0 1 0 2h-1.1a1 1 0 1 1 0-2H23ZM10.044 4.05a7.005 7.005 0 0 1 9.905 9.906h0l-.737.746a1 1 0 0 1-1.424-1.404l.742-.752.004-.005a5.003 5.003 0 1 0-7.075-7.075l-.005.004-.752.742a1 1 0 0 1-1.404-1.424l.746-.737ZM2.1 7.7a1 1 0 1 1 0 2H1a1 1 0 0 1 0-2h1.1Zm-.157-5.757a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM7.7 2.1V1a1 1 0 1 1 2 0v1.1a1 1 0 0 1-2 0Z',
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ export const EditBig_Stroke1_Corner0_Rounded = createSinglePathSVG({
|
|||||||
path: 'M19.667 4.458a1 1 0 1 0 0-2v2Zm25 23a1 1 0 0 0-2 0h2ZM3.912 45.543l.454-.891-.454.89Zm-2.33-2.33.89-.455h0l-.89.454Zm39.173 2.33-.454-.891h0l.454.89Zm2.33-2.33-.89-.455.89.454ZM1.581 6.37l-.89-.454h0l.89.454Zm2.331-2.331-.454-.891.454.89ZM14.333 32.79h-1a1 1 0 0 0 1 1v-1Zm.781-8.781.707.707-.707-.707ZM36.562 2.562l-.707-.707v0l.707.707Zm7.543 0-.707.707v0l.707-.707Zm.457.458.707-.707v0l-.707.707Zm0 7.542.707.707-.707-.707ZM23.114 32.01l.707.707-.707-.707Zm12.02 14.114v-1h-25.6v2h25.6v-1ZM1 37.591h1v-25.6H0v25.6h1ZM9.533 3.458v1h10.134v-2H9.533v1Zm34.134 24h-1V37.59h2V27.457h-1ZM9.533 46.124v-1c-1.51 0-2.582 0-3.421-.07-.828-.067-1.34-.195-1.746-.402l-.454.89-.454.892c.735.374 1.54.537 2.491.614.94.077 2.107.076 3.584.076v-1ZM1 37.591H0c0 1.477 0 2.645.076 3.584.078.951.24 1.756.614 2.491l.891-.454.891-.454c-.207-.406-.335-.918-.403-1.746C2.001 40.173 2 39.101 2 37.591H1Zm2.912 7.952.454-.891a4.33 4.33 0 0 1-1.894-1.894l-.89.454-.892.454a6.33 6.33 0 0 0 2.768 2.768l.454-.891Zm31.221.581v1c1.477 0 2.645.001 3.585-.076.95-.078 1.756-.24 2.49-.614l-.453-.891-.454-.891c-.406.207-.919.335-1.746.403-.84.068-1.912.07-3.422.07v1Zm8.534-8.533h-1c0 1.51-.001 2.582-.07 3.421-.067.828-.196 1.34-.403 1.746l.891.454.891.454c.375-.735.537-1.54.615-2.49.076-.94.076-2.108.076-3.585h-1Zm-2.912 7.952.454.89a6.33 6.33 0 0 0 2.767-2.767l-.89-.454-.892-.454a4.33 4.33 0 0 1-1.893 1.894l.454.89ZM1 11.99h1c0-1.51 0-2.582.07-3.422.067-.827.195-1.34.402-1.745l-.89-.454-.892-.454c-.374.734-.536 1.54-.614 2.49C-.001 9.345 0 10.513 0 11.99h1Zm8.533-8.533v-1c-1.477 0-2.645-.001-3.584.076-.951.077-1.756.24-2.49.614l.453.89.454.892c.406-.207.918-.336 1.746-.403.839-.069 1.911-.07 3.421-.07v-1ZM1.581 6.37l.891.454A4.33 4.33 0 0 1 4.366 4.93l-.454-.891-.454-.891A6.33 6.33 0 0 0 .69 5.916l.891.454Zm12.752 19.525h-1v6.896h2v-6.896h-1Zm0 6.896v1h6.896v-2h-6.896v1Zm.781-8.781.707.707L37.27 3.269l-.707-.707-.707-.707-21.448 21.448.707.707Zm28.99-21.448-.706.707.457.458.707-.707.707-.707-.457-.458-.707.707Zm.458 8-.707-.707-21.448 21.448.707.707.707.707L45.27 11.269l-.707-.707Zm0-7.542-.707.707a4.333 4.333 0 0 1 0 6.128l.707.707.707.707a6.333 6.333 0 0 0 0-8.956l-.707.707Zm-8-.458.707.707a4.333 4.333 0 0 1 6.129 0l.707-.707.707-.707a6.333 6.333 0 0 0-8.957 0l.707.707ZM21.23 32.791v1c.972 0 1.905-.386 2.593-1.074l-.708-.707-.707-.707a1.67 1.67 0 0 1-1.178.488v1Zm-6.896-6.896h1c0-.442.176-.866.489-1.178l-.708-.707-.707-.707a3.67 3.67 0 0 0-1.074 2.592h1Z',
|
path: 'M19.667 4.458a1 1 0 1 0 0-2v2Zm25 23a1 1 0 0 0-2 0h2ZM3.912 45.543l.454-.891-.454.89Zm-2.33-2.33.89-.455h0l-.89.454Zm39.173 2.33-.454-.891h0l.454.89Zm2.33-2.33-.89-.455.89.454ZM1.581 6.37l-.89-.454h0l.89.454Zm2.331-2.331-.454-.891.454.89ZM14.333 32.79h-1a1 1 0 0 0 1 1v-1Zm.781-8.781.707.707-.707-.707ZM36.562 2.562l-.707-.707v0l.707.707Zm7.543 0-.707.707v0l.707-.707Zm.457.458.707-.707v0l-.707.707Zm0 7.542.707.707-.707-.707ZM23.114 32.01l.707.707-.707-.707Zm12.02 14.114v-1h-25.6v2h25.6v-1ZM1 37.591h1v-25.6H0v25.6h1ZM9.533 3.458v1h10.134v-2H9.533v1Zm34.134 24h-1V37.59h2V27.457h-1ZM9.533 46.124v-1c-1.51 0-2.582 0-3.421-.07-.828-.067-1.34-.195-1.746-.402l-.454.89-.454.892c.735.374 1.54.537 2.491.614.94.077 2.107.076 3.584.076v-1ZM1 37.591H0c0 1.477 0 2.645.076 3.584.078.951.24 1.756.614 2.491l.891-.454.891-.454c-.207-.406-.335-.918-.403-1.746C2.001 40.173 2 39.101 2 37.591H1Zm2.912 7.952.454-.891a4.33 4.33 0 0 1-1.894-1.894l-.89.454-.892.454a6.33 6.33 0 0 0 2.768 2.768l.454-.891Zm31.221.581v1c1.477 0 2.645.001 3.585-.076.95-.078 1.756-.24 2.49-.614l-.453-.891-.454-.891c-.406.207-.919.335-1.746.403-.84.068-1.912.07-3.422.07v1Zm8.534-8.533h-1c0 1.51-.001 2.582-.07 3.421-.067.828-.196 1.34-.403 1.746l.891.454.891.454c.375-.735.537-1.54.615-2.49.076-.94.076-2.108.076-3.585h-1Zm-2.912 7.952.454.89a6.33 6.33 0 0 0 2.767-2.767l-.89-.454-.892-.454a4.33 4.33 0 0 1-1.893 1.894l.454.89ZM1 11.99h1c0-1.51 0-2.582.07-3.422.067-.827.195-1.34.402-1.745l-.89-.454-.892-.454c-.374.734-.536 1.54-.614 2.49C-.001 9.345 0 10.513 0 11.99h1Zm8.533-8.533v-1c-1.477 0-2.645-.001-3.584.076-.951.077-1.756.24-2.49.614l.453.89.454.892c.406-.207.918-.336 1.746-.403.839-.069 1.911-.07 3.421-.07v-1ZM1.581 6.37l.891.454A4.33 4.33 0 0 1 4.366 4.93l-.454-.891-.454-.891A6.33 6.33 0 0 0 .69 5.916l.891.454Zm12.752 19.525h-1v6.896h2v-6.896h-1Zm0 6.896v1h6.896v-2h-6.896v1Zm.781-8.781.707.707L37.27 3.269l-.707-.707-.707-.707-21.448 21.448.707.707Zm28.99-21.448-.706.707.457.458.707-.707.707-.707-.457-.458-.707.707Zm.458 8-.707-.707-21.448 21.448.707.707.707.707L45.27 11.269l-.707-.707Zm0-7.542-.707.707a4.333 4.333 0 0 1 0 6.128l.707.707.707.707a6.333 6.333 0 0 0 0-8.956l-.707.707Zm-8-.458.707.707a4.333 4.333 0 0 1 6.129 0l.707-.707.707-.707a6.333 6.333 0 0 0-8.957 0l.707.707ZM21.23 32.791v1c.972 0 1.905-.386 2.593-1.074l-.708-.707-.707-.707a1.67 1.67 0 0 1-1.178.488v1Zm-6.896-6.896h1c0-.442.176-.866.489-1.178l-.708-.707-.707-.707a3.67 3.67 0 0 0-1.074 2.592h1Z',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use EditBig_Stroke2_Corner2_Rounded
|
||||||
|
*/
|
||||||
export const EditBig_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
export const EditBig_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
path: 'M17.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-9 9A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l9-9ZM10 12.414V14h1.586l8-8L18 4.414l-8 8ZM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Z',
|
path: 'M17.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-9 9A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l9-9ZM10 12.414V14h1.586l8-8L18 4.414l-8 8ZM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Z',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const EditBig_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M3 16.8V7.2c0-.544-.001-1.011.03-1.395.033-.395.104-.789.297-1.167a3 3 0 0 1 1.31-1.31c.379-.193.772-.265 1.168-.297C6.188 2.999 6.657 3 7.2 3H11a1 1 0 1 1 0 2H7.2c-.576 0-.949 0-1.232.023-.272.022-.373.06-.422.085a1 1 0 0 0-.437.437c-.025.05-.062.15-.085.422C5.001 6.251 5 6.623 5 7.2v9.6c0 .577.001.95.024 1.232.023.272.06.373.085.422a1 1 0 0 0 .437.437c.05.025.15.063.422.085.283.023.656.024 1.232.024h9.6c.576 0 .949-.001 1.232-.024.272-.022.373-.06.422-.085a1 1 0 0 0 .437-.437c.025-.049.062-.15.085-.422.023-.283.024-.655.024-1.232V13a1 1 0 1 1 2 0v3.8c0 .543.001 1.011-.03 1.395-.033.395-.104.788-.297 1.167a3 3 0 0 1-1.31 1.311c-.379.193-.772.264-1.168.296-.383.031-.852.031-1.395.031H7.2c-.543 0-1.012 0-1.395-.031-.396-.032-.789-.103-1.167-.296a3 3 0 0 1-1.31-1.311c-.194-.379-.265-.772-.298-1.167C3 17.81 3 17.343 3 16.8M16.629 2.957a3 3 0 0 1 4.242 0l.172.171a3 3 0 0 1 0 4.243L13 15.414a2 2 0 0 1-1.414.586H9a1 1 0 0 1-1-1v-2.586A2 2 0 0 1 8.586 11zM10 14h1.586l8.043-8.043a1 1 0 0 0 0-1.414l-.172-.172a1 1 0 0 0-1.414 0L10 12.414z',
|
||||||
|
})
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ export const Lock_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
|||||||
export const Lock_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
export const Lock_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||||
path: 'M7 7a5 5 0 0 1 10 0v2a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7Zm0 4a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7Zm8-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z',
|
path: 'M7 7a5 5 0 0 1 10 0v2a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7Zm0 4a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7Zm8-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const Unlock_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1zm5 2a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1',
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import {createSinglePathSVG} from './TEMPLATE'
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use SquareBehindSquare_Stroke2_Corner2_Rounded
|
||||||
|
*/
|
||||||
export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z',
|
path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const SquareBehindSquare_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z',
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import {useCallback, useInsertionEffect, useRef} from 'react'
|
import {useCallback, useInsertionEffect, useRef} from 'react'
|
||||||
|
|
||||||
// This should be used sparingly. It erases reactivity, i.e. when the inputs
|
const noop = () => {}
|
||||||
// change, the function itself will remain the same. This means that if you
|
|
||||||
// use this at a higher level of your tree, and then some state you read in it
|
/**
|
||||||
// changes, there is no mechanism for anything below in the tree to "react"
|
* This should be used sparingly. It erases reactivity, i.e. when the inputs
|
||||||
// to this change (e.g. by knowing to call your function again).
|
* change, the function itself will remain the same. This means that if you use
|
||||||
//
|
* this at a higher level of your tree, and then some state you read in it
|
||||||
// Also, you should avoid calling the returned function during rendering
|
* changes, there is no mechanism for anything below in the tree to "react" to
|
||||||
// since the values captured by it are going to lag behind.
|
* this change (e.g. by knowing to call your function again).
|
||||||
export function useNonReactiveCallback<T extends Function>(fn: T): T {
|
*
|
||||||
const ref = useRef(fn)
|
* Also, you should avoid calling the returned function during rendering since
|
||||||
|
* the values captured by it are going to lag behind.
|
||||||
|
*
|
||||||
|
* For objects, see `useNonReactiveObject` instead.
|
||||||
|
*/
|
||||||
|
export function useNonReactiveCallback<T extends Function = () => void>(
|
||||||
|
fn?: T,
|
||||||
|
): T {
|
||||||
|
const ref = useRef<T>((fn ?? noop) as T)
|
||||||
useInsertionEffect(() => {
|
useInsertionEffect(() => {
|
||||||
ref.current = fn
|
ref.current = (fn ?? noop) as T
|
||||||
}, [fn])
|
}, [fn])
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(...args: any) => {
|
(...args: any) => {
|
||||||
|
|||||||
@@ -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'
|
| 'reply'
|
||||||
| 'quote'
|
| 'quote'
|
||||||
| 'chat-message'
|
| 'chat-message'
|
||||||
|
| 'chat-reaction'
|
||||||
| 'starterpack-joined'
|
| 'starterpack-joined'
|
||||||
| 'like-via-repost'
|
| 'like-via-repost'
|
||||||
| 'repost-via-repost'
|
| 'repost-via-repost'
|
||||||
@@ -44,7 +45,7 @@ export type NotificationReason =
|
|||||||
export type NotificationPayload =
|
export type NotificationPayload =
|
||||||
| undefined
|
| undefined
|
||||||
| {
|
| {
|
||||||
reason: Exclude<NotificationReason, 'chat-message'>
|
reason: Exclude<NotificationReason, 'chat-message' | 'chat-reaction'>
|
||||||
uri: string
|
uri: string
|
||||||
subject: string
|
subject: string
|
||||||
recipientDid: string
|
recipientDid: string
|
||||||
@@ -55,6 +56,12 @@ export type NotificationPayload =
|
|||||||
messageId: string
|
messageId: string
|
||||||
recipientDid: string
|
recipientDid: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
reason: 'chat-reaction'
|
||||||
|
convoId: string
|
||||||
|
messageId: string
|
||||||
|
recipientDid: string
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_HANDLER_OPTIONS = {
|
const DEFAULT_HANDLER_OPTIONS = {
|
||||||
shouldShowBanner: false,
|
shouldShowBanner: false,
|
||||||
@@ -192,8 +199,11 @@ export function useNotificationsHandler() {
|
|||||||
const handleNotification = (payload?: NotificationPayload) => {
|
const handleNotification = (payload?: NotificationPayload) => {
|
||||||
if (!payload) return
|
if (!payload) return
|
||||||
|
|
||||||
if (payload.reason === 'chat-message') {
|
if (
|
||||||
logger.debug(`useNotificationsHandler: handling chat message`, {
|
payload.reason === 'chat-message' ||
|
||||||
|
payload.reason === 'chat-reaction'
|
||||||
|
) {
|
||||||
|
logger.debug(`useNotificationsHandler: handling chat notification`, {
|
||||||
payload,
|
payload,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -270,7 +280,8 @@ export function useNotificationsHandler() {
|
|||||||
logger.debug('useNotificationsHandler: incoming', {e, payload})
|
logger.debug('useNotificationsHandler: incoming', {e, payload})
|
||||||
|
|
||||||
if (
|
if (
|
||||||
payload.reason === 'chat-message' &&
|
(payload.reason === 'chat-message' ||
|
||||||
|
payload.reason === 'chat-reaction') &&
|
||||||
payload.recipientDid === currentAccount?.did
|
payload.recipientDid === currentAccount?.did
|
||||||
) {
|
) {
|
||||||
const shouldAlert = payload.convoId !== currentConvoId
|
const shouldAlert = payload.convoId !== currentConvoId
|
||||||
@@ -341,7 +352,8 @@ export function useNotificationsHandler() {
|
|||||||
// Whenever there's a stored payload, that means we had to switch accounts before handling the notification.
|
// Whenever there's a stored payload, that means we had to switch accounts before handling the notification.
|
||||||
// Whenever currentAccount changes, we should try to handle it again.
|
// Whenever currentAccount changes, we should try to handle it again.
|
||||||
if (
|
if (
|
||||||
storedAccountSwitchPayload?.reason === 'chat-message' &&
|
(storedAccountSwitchPayload?.reason === 'chat-message' ||
|
||||||
|
storedAccountSwitchPayload?.reason === 'chat-reaction') &&
|
||||||
currentAccount?.did === storedAccountSwitchPayload.recipientDid
|
currentAccount?.did === storedAccountSwitchPayload.recipientDid
|
||||||
) {
|
) {
|
||||||
handleNotification(storedAccountSwitchPayload)
|
handleNotification(storedAccountSwitchPayload)
|
||||||
@@ -429,6 +441,7 @@ export function notificationToURL(payload: NotificationPayload): string | null {
|
|||||||
return `/profile/${urip.host}`
|
return `/profile/${urip.host}`
|
||||||
}
|
}
|
||||||
case 'chat-message':
|
case 'chat-message':
|
||||||
|
case 'chat-reaction':
|
||||||
// should be handled separately
|
// should be handled separately
|
||||||
return null
|
return null
|
||||||
case 'verified':
|
case 'verified':
|
||||||
|
|||||||
@@ -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