Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e75948f91 | |||
| 6a442675d9 | |||
| 2886816c1e |
@@ -15,11 +15,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: bskyweb/go.mod
|
||||
go-version: "1.25"
|
||||
- name: Dummy Static Files
|
||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
|
||||
- name: Check
|
||||
@@ -32,11 +32,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: bskyweb/go.mod
|
||||
go-version: "1.25"
|
||||
- name: Dummy Static Files
|
||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
|
||||
- name: Lint
|
||||
|
||||
@@ -29,9 +29,8 @@ yarn lint # Run ESLint
|
||||
yarn typecheck # Run TypeScript type checking
|
||||
|
||||
# Internationalization
|
||||
# DO NOT run these commands - extraction and compilation are handled by CI
|
||||
yarn intl:extract # Extract translation strings (nightly CI job)
|
||||
yarn intl:compile # Compile translations for runtime (nightly CI job)
|
||||
yarn intl:extract # Extract translation strings
|
||||
yarn intl:compile # Compile translations for runtime
|
||||
|
||||
# Build
|
||||
yarn build-web # Build web version
|
||||
@@ -120,7 +119,7 @@ if (gtMobile) {
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- Spacing: `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl` (t-shirt sizes)
|
||||
- Spacing: `xxs`, `xs`, `sm`, `md`, `lg`, `xl`, `xxl` (t-shirt sizes)
|
||||
- Text: `text_xs`, `text_sm`, `text_md`, `text_lg`, `text_xl`
|
||||
- Gaps/Padding: `gap_sm`, `p_md`, `px_lg`, `py_xl`
|
||||
- Flex: `flex_row`, `flex_1`, `align_center`, `justify_between`
|
||||
@@ -145,8 +144,7 @@ function MyFeature() {
|
||||
</Button>
|
||||
|
||||
<Dialog.Outer control={control}>
|
||||
{/* Typically the inner part is in its own component */}
|
||||
<Dialog.Handle /> {/* Native-only drag handle */}
|
||||
<Dialog.Handle /> {/* Native drag handle */}
|
||||
<Dialog.ScrollableInner label={_(msg`My Dialog`)}>
|
||||
<Dialog.Header>
|
||||
<Dialog.HeaderText>Title</Dialog.HeaderText>
|
||||
@@ -154,10 +152,9 @@ function MyFeature() {
|
||||
|
||||
<Text>Dialog content here</Text>
|
||||
|
||||
<Button label="Done" onPress={() => control.close()}>
|
||||
<ButtonText>Done</ButtonText>
|
||||
<Button label="Close" onPress={() => control.close()}>
|
||||
<ButtonText>Close</ButtonText>
|
||||
</Button>
|
||||
<Dialog.Close /> {/* Web-only X button in top left */}
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
@@ -218,7 +215,7 @@ import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||
|
||||
// Icon-only button
|
||||
<Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round">
|
||||
<ButtonIcon icon={XIcon} />
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
|
||||
// Ghost variant (deprecated - use color prop)
|
||||
@@ -228,7 +225,7 @@ import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||
```
|
||||
|
||||
**Button Props:**
|
||||
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
|
||||
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'`
|
||||
- `size`: `'tiny'` | `'small'` | `'large'`
|
||||
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
|
||||
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`)
|
||||
@@ -300,9 +297,8 @@ function MyComponent() {
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
# DO NOT run these commands - extraction and compilation are handled by a nightly CI job
|
||||
yarn intl:extract # Extract new strings to locale files
|
||||
yarn intl:compile # Compile translations for runtime
|
||||
yarn intl:compile # Compile for runtime (required after changes)
|
||||
```
|
||||
|
||||
## State Management
|
||||
@@ -343,16 +339,6 @@ export function useUpdateProfile() {
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({queryKey: RQKEY(variables.did)})
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isNetworkError(error)) {
|
||||
// don't log, but inform user
|
||||
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
|
||||
// XRPC APIs often have typed errors, allows nicer handling
|
||||
} else {
|
||||
// Log unexpected errors to Sentry
|
||||
logger.error('Error updating profile', {safeMessage: error})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
@@ -366,26 +352,6 @@ STALE.HOURS.ONE // 1 hour
|
||||
STALE.INFINITY // Never stale
|
||||
```
|
||||
|
||||
**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
|
||||
|
||||
```tsx
|
||||
export function useDraftsQuery() {
|
||||
const agent = useAgent()
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['drafts'],
|
||||
queryFn: async ({pageParam}) => {
|
||||
const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
|
||||
return res.data
|
||||
},
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: page => page.cursor,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []`
|
||||
|
||||
### Preferences (React Context)
|
||||
|
||||
```tsx
|
||||
@@ -471,19 +437,7 @@ Example from Dialog:
|
||||
- `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:
|
||||
|
||||
```tsx
|
||||
// CORRECT - bundler picks storage.ts or storage.web.ts automatically
|
||||
import * as storage from '#/state/drafts/storage'
|
||||
|
||||
// WRONG - don't use require() or conditional imports for platform files
|
||||
const storage = IS_NATIVE
|
||||
? require('#/state/drafts/storage')
|
||||
: require('#/state/drafts/storage.web')
|
||||
```
|
||||
|
||||
Platform detection (for runtime logic, not imports):
|
||||
Platform detection:
|
||||
```tsx
|
||||
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
// @ts-check
|
||||
const pkg = require('./package.json')
|
||||
|
||||
/**
|
||||
* @param {import('@expo/config-types').ExpoConfig} _config
|
||||
* @returns {{ expo: import('@expo/config-types').ExpoConfig }}
|
||||
*/
|
||||
module.exports = function (_config) {
|
||||
/**
|
||||
* App version number. Should be incremented as part of a release cycle.
|
||||
@@ -20,7 +15,7 @@ module.exports = function (_config) {
|
||||
|
||||
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
|
||||
const IS_DEV = !IS_TESTFLIGHT && !IS_PRODUCTION
|
||||
const IS_DEV = !IS_TESTFLIGHT || !IS_PRODUCTION
|
||||
|
||||
const ASSOCIATED_DOMAINS = [
|
||||
'applinks:bsky.app',
|
||||
@@ -119,7 +114,6 @@ module.exports = function (_config) {
|
||||
'com.apple.developer.kernel.increased-memory-limit': true,
|
||||
'com.apple.developer.kernel.extended-virtual-addressing': true,
|
||||
'com.apple.security.application-groups': 'group.app.bsky',
|
||||
// 'com.apple.developer.device-information.user-assigned-device-name': true,
|
||||
},
|
||||
privacyManifests: {
|
||||
NSPrivacyCollectedDataTypes: [
|
||||
@@ -198,14 +192,10 @@ module.exports = function (_config) {
|
||||
scheme: 'https',
|
||||
host: 'bsky.app',
|
||||
},
|
||||
...(IS_DEV
|
||||
? [
|
||||
{
|
||||
scheme: 'http',
|
||||
host: 'localhost:19006',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
IS_DEV && {
|
||||
scheme: 'http',
|
||||
host: 'localhost:19006',
|
||||
},
|
||||
],
|
||||
category: ['BROWSABLE', 'DEFAULT'],
|
||||
},
|
||||
@@ -237,25 +227,20 @@ module.exports = function (_config) {
|
||||
'react-native-edge-to-edge',
|
||||
{android: {enforceNavigationBarContrast: false}},
|
||||
],
|
||||
...(USE_SENTRY
|
||||
? [
|
||||
/** @type {[string, any]} */ ([
|
||||
'@sentry/react-native/expo',
|
||||
{
|
||||
organization: 'blueskyweb',
|
||||
project: 'app',
|
||||
url: 'https://sentry.io',
|
||||
},
|
||||
]),
|
||||
]
|
||||
: []),
|
||||
USE_SENTRY && [
|
||||
'@sentry/react-native/expo',
|
||||
{
|
||||
organization: 'blueskyweb',
|
||||
project: 'app',
|
||||
url: 'https://sentry.io',
|
||||
},
|
||||
],
|
||||
[
|
||||
'expo-build-properties',
|
||||
{
|
||||
ios: {
|
||||
deploymentTarget: '15.1',
|
||||
buildReactNativeFromSource: true,
|
||||
ccacheEnabled: IS_DEV,
|
||||
},
|
||||
android: {
|
||||
compileSdkVersion: 35,
|
||||
@@ -312,25 +297,25 @@ module.exports = function (_config) {
|
||||
'expo-splash-screen',
|
||||
{
|
||||
ios: {
|
||||
enableFullScreenImage_legacy: true, // iOS only
|
||||
backgroundColor: '#006AFF', // primary_500
|
||||
image: './assets/splash/splash.png',
|
||||
enableFullScreenImage_legacy: true,
|
||||
backgroundColor: '#ffffff',
|
||||
image: './assets/splash.png',
|
||||
resizeMode: 'cover',
|
||||
dark: {
|
||||
enableFullScreenImage_legacy: true, // iOS only
|
||||
backgroundColor: '#002861', // primary_900
|
||||
image: './assets/splash/splash-dark.png',
|
||||
enableFullScreenImage_legacy: true,
|
||||
backgroundColor: '#001429',
|
||||
image: './assets/splash-dark.png',
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
},
|
||||
android: {
|
||||
backgroundColor: '#006AFF', // primary_500
|
||||
image: './assets/splash/android-splash-logo-white.png',
|
||||
imageWidth: 102, // even division of 306px
|
||||
backgroundColor: '#0c7cff',
|
||||
image: './assets/splash-android-icon.png',
|
||||
imageWidth: 150,
|
||||
dark: {
|
||||
backgroundColor: '#002861', // primary_900
|
||||
image: './assets/splash/android-splash-logo-white.png',
|
||||
imageWidth: 102,
|
||||
backgroundColor: '#0c2a49',
|
||||
image: './assets/splash-android-icon-dark.png',
|
||||
imageWidth: 150,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -411,7 +396,7 @@ module.exports = function (_config) {
|
||||
'I agree to allow Bluesky to use my contacts for friend discovery until I opt out.',
|
||||
},
|
||||
],
|
||||
],
|
||||
].filter(Boolean),
|
||||
extra: {
|
||||
eas: {
|
||||
build: {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#000" d="M32.457 7c1.68 0 3.29.668 4.478 1.855L49.813 21.73a6.33 6.33 0 0 1 1.854 4.479v24.458A6.333 6.333 0 0 1 45.333 57H18.666a6.334 6.334 0 0 1-6.333-6.333V13.333A6.334 6.334 0 0 1 18.666 7h13.791ZM18.666 9a4.334 4.334 0 0 0-4.333 4.333v37.334A4.334 4.334 0 0 0 18.666 55h26.667a4.333 4.333 0 0 0 4.333-4.333V26.209c0-.418-.061-.829-.177-1.223a1 1 0 0 1-.155.014H40a6.334 6.334 0 0 1-6.325-6.008l-.008-.326V9.333q0-.08.013-.156A4.3 4.3 0 0 0 32.457 9H18.666Zm18.627 22.293a1 1 0 1 1 1.414 1.414L33.414 38l5.293 5.293a1 1 0 1 1-1.414 1.414L32 39.414l-5.293 5.293a1 1 0 1 1-1.414-1.414L30.586 38l-5.293-5.293a1 1 0 1 1 1.414-1.414L32 36.586l5.293-5.293Zm-1.626-12.627.006.224A4.333 4.333 0 0 0 40 23h8.253L35.667 10.414v8.252Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 822 B |
|
Before Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 606 KiB After Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 563 KiB After Width: | Height: | Size: 563 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -77,7 +77,6 @@ export default defineConfig(
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
@@ -121,7 +120,6 @@ export default defineConfig(
|
||||
],
|
||||
'bsky-internal/use-exact-imports': 'error',
|
||||
'bsky-internal/use-prefixed-imports': 'error',
|
||||
'bsky-internal/lingui-msg-rule': 'error',
|
||||
|
||||
/**
|
||||
* React & React Native
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
const {RuleTester} = require('eslint')
|
||||
const tseslint = require('typescript-eslint')
|
||||
const linguiMsgRule = require('../lingui-msg-rule')
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe('lingui-msg-rule', () => {
|
||||
const tests = {
|
||||
valid: [
|
||||
// msg template literal
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _(msg\`Hello\`)
|
||||
`,
|
||||
},
|
||||
// msg template literal with interpolation
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const name = 'World'
|
||||
const x = _(msg\`Hello \${name}\`)
|
||||
`,
|
||||
},
|
||||
// plural macro
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const count = 5
|
||||
const x = _(plural(count, {one: '# item', other: '# items'}))
|
||||
`,
|
||||
},
|
||||
// select macro
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const gender = 'female'
|
||||
const x = _(select(gender, {male: 'He', female: 'She', other: 'They'}))
|
||||
`,
|
||||
},
|
||||
// selectOrdinal macro
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const position = 1
|
||||
const x = _(selectOrdinal(position, {one: '#st', two: '#nd', few: '#rd', other: '#th'}))
|
||||
`,
|
||||
},
|
||||
// msg function call with object (descriptor form)
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _(msg({message: 'Hello'}))
|
||||
`,
|
||||
},
|
||||
// msg function call with object and context
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _(msg({message: 'Hello', context: 'greeting'}))
|
||||
`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
// Plain string literal (single quotes) - with auto-fix
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _('Bad')
|
||||
`,
|
||||
output: `
|
||||
const {_} = useLingui()
|
||||
const x = _(msg\`Bad\`)
|
||||
`,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// Plain string literal (double quotes) - with auto-fix
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _("Bad")
|
||||
`,
|
||||
output: `
|
||||
const {_} = useLingui()
|
||||
const x = _(msg\`Bad\`)
|
||||
`,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// Template literal without msg tag - with auto-fix
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _(\`Bad\`)
|
||||
`,
|
||||
output: `
|
||||
const {_} = useLingui()
|
||||
const x = _(msg\`Bad\`)
|
||||
`,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// Template literal with interpolation - with auto-fix
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const name = 'World'
|
||||
const x = _(\`Hello \${name}\`)
|
||||
`,
|
||||
output: `
|
||||
const {_} = useLingui()
|
||||
const name = 'World'
|
||||
const x = _(msg\`Hello \${name}\`)
|
||||
`,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// String with backticks that need escaping
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _('Use \\\`code\\\` here')
|
||||
`,
|
||||
output: `
|
||||
const {_} = useLingui()
|
||||
const x = _(msg\`Use \\\`code\\\` here\`)
|
||||
`,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// Variable/identifier - no auto-fix possible
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const message = 'Hello'
|
||||
const x = _(message)
|
||||
`,
|
||||
output: null,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// Arbitrary function call - no auto-fix possible
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _(getMessage())
|
||||
`,
|
||||
output: null,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// Empty call - no auto-fix possible
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _()
|
||||
`,
|
||||
output: null,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// Tagged template with wrong tag - no auto-fix (would need to replace tag)
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _(html\`Hello\`)
|
||||
`,
|
||||
output: null,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// Number literal - no auto-fix possible
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _(123)
|
||||
`,
|
||||
output: null,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
ruleTester.run('lingui-msg-rule', linguiMsgRule, tests)
|
||||
})
|
||||
@@ -9,7 +9,6 @@ const plugin = {
|
||||
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
|
||||
'use-exact-imports': require('./use-exact-imports'),
|
||||
'use-prefixed-imports': require('./use-prefixed-imports'),
|
||||
'lingui-msg-rule': require('./lingui-msg-rule'),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* @type {import('eslint').Rule.RuleModule}
|
||||
*/
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Enforce that Lingui _() function is called with msg`` template literal or plural/select macros',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: 'code',
|
||||
messages: {
|
||||
missingMsg:
|
||||
'Lingui _() must be called with msg`...` or msg({...}) or plural/select/selectOrdinal. Example: _(msg`Hello`)',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
|
||||
create(context) {
|
||||
// Valid Lingui macro functions that can be passed to _()
|
||||
const VALID_MACRO_FUNCTIONS = new Set([
|
||||
'msg',
|
||||
'plural',
|
||||
'select',
|
||||
'selectOrdinal',
|
||||
])
|
||||
|
||||
/**
|
||||
* Escape backticks and backslashes for template literal
|
||||
*/
|
||||
function escapeForTemplateLiteral(str) {
|
||||
return str.replace(/\\`/g, '`').replace(/`/g, '\\`')
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to get a fixer for the given argument
|
||||
* Returns null if we can't safely fix it
|
||||
*/
|
||||
function getFixer(firstArg) {
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode()
|
||||
|
||||
// Fix string literals: _('foo') -> _(msg`foo`)
|
||||
if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') {
|
||||
const escaped = escapeForTemplateLiteral(firstArg.value)
|
||||
return function (fixer) {
|
||||
return fixer.replaceText(firstArg, 'msg`' + escaped + '`')
|
||||
}
|
||||
}
|
||||
|
||||
// Fix untagged template literals: _(`foo`) -> _(msg`foo`)
|
||||
if (firstArg.type === 'TemplateLiteral') {
|
||||
const text = sourceCode.getText(firstArg)
|
||||
return function (fixer) {
|
||||
return fixer.replaceText(firstArg, 'msg' + text)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
// Check if this is a call to _()
|
||||
if (node.callee.type !== 'Identifier' || node.callee.name !== '_') {
|
||||
return
|
||||
}
|
||||
|
||||
// Must have at least one argument
|
||||
if (node.arguments.length === 0) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'missingMsg',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const firstArg = node.arguments[0]
|
||||
|
||||
// Valid: _(msg`...`)
|
||||
if (
|
||||
firstArg.type === 'TaggedTemplateExpression' &&
|
||||
firstArg.tag.type === 'Identifier' &&
|
||||
firstArg.tag.name === 'msg'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Valid: _(msg(...)), _(plural(...)), _(select(...)), _(selectOrdinal(...))
|
||||
if (
|
||||
firstArg.type === 'CallExpression' &&
|
||||
firstArg.callee.type === 'Identifier' &&
|
||||
VALID_MACRO_FUNCTIONS.has(firstArg.callee.name)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Everything else is invalid
|
||||
const fix = getFixer(firstArg)
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'missingMsg',
|
||||
fix,
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -44,6 +44,6 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation project(':expo-modules-core')
|
||||
implementation 'com.google.android.material:material:1.13.0'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation "com.facebook.react:react-native:+"
|
||||
}
|
||||
|
||||
@@ -5,12 +5,8 @@ import android.util.DisplayMetrics
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewStructure
|
||||
import android.view.Window
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.core.view.allViews
|
||||
import com.facebook.react.bridge.LifecycleEventListener
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
@@ -19,7 +15,6 @@ import com.facebook.react.uimanager.UIManagerHelper
|
||||
import com.facebook.react.uimanager.events.EventDispatcher
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.internal.EdgeToEdgeUtils
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
@@ -34,26 +29,22 @@ class BottomSheetView(
|
||||
|
||||
private lateinit var dialogRootViewGroup: DialogRootViewGroup
|
||||
private var eventDispatcher: EventDispatcher? = null
|
||||
private var isKeyboardVisible: Boolean = false
|
||||
|
||||
private val screenHeight =
|
||||
private val rawScreenHeight =
|
||||
context.resources.displayMetrics.heightPixels
|
||||
.toFloat()
|
||||
private val safeScreenHeight = (rawScreenHeight - getNavigationBarHeight()).toFloat()
|
||||
|
||||
private fun getNavigationBarHeight(): Int {
|
||||
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
|
||||
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
|
||||
}
|
||||
|
||||
private fun getStatusBarHeight(): Int {
|
||||
val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
|
||||
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
|
||||
}
|
||||
|
||||
private val onAttemptDismiss by EventDispatcher()
|
||||
private val onSnapPointChange by EventDispatcher()
|
||||
private val onStateChange by EventDispatcher()
|
||||
|
||||
// Props
|
||||
var disableDrag = false
|
||||
set(value) {
|
||||
field = value
|
||||
@@ -65,31 +56,48 @@ class BottomSheetView(
|
||||
field = value
|
||||
this.dialog?.setCancelable(!value)
|
||||
}
|
||||
|
||||
var preventExpansion = false
|
||||
|
||||
var minHeight = 0f
|
||||
set(value) {
|
||||
field = if (value < 0) 0f else dpToPx(value)
|
||||
field =
|
||||
if (value < 0) {
|
||||
0f
|
||||
} else {
|
||||
dpToPx(value)
|
||||
}
|
||||
}
|
||||
|
||||
var maxHeight = this.screenHeight
|
||||
var maxHeight = this.safeScreenHeight
|
||||
set(value) {
|
||||
val px = dpToPx(value)
|
||||
field = if (px > this.screenHeight) this.screenHeight else px
|
||||
field =
|
||||
if (px > this.safeScreenHeight) {
|
||||
this.safeScreenHeight
|
||||
} else {
|
||||
px
|
||||
}
|
||||
}
|
||||
|
||||
private var isOpen: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
onStateChange(mapOf("state" to if (value) "open" else "closed"))
|
||||
onStateChange(
|
||||
mapOf(
|
||||
"state" to if (value) "open" else "closed",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private var isOpening: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
if (value) {
|
||||
onStateChange(mapOf("state" to "opening"))
|
||||
onStateChange(
|
||||
mapOf(
|
||||
"state" to "opening",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,21 +105,33 @@ class BottomSheetView(
|
||||
set(value) {
|
||||
field = value
|
||||
if (value) {
|
||||
onStateChange(mapOf("state" to "closing"))
|
||||
onStateChange(
|
||||
mapOf(
|
||||
"state" to "closing",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedSnapPoint = 0
|
||||
set(value) {
|
||||
if (field == value) return
|
||||
|
||||
field = value
|
||||
onSnapPointChange(mapOf("snapPoint" to value))
|
||||
onSnapPointChange(
|
||||
mapOf(
|
||||
"snapPoint" to value,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
init {
|
||||
(appContext.reactContext as? ReactContext)?.let {
|
||||
it.addLifecycleEventListener(this)
|
||||
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
|
||||
|
||||
this.dialogRootViewGroup = DialogRootViewGroup(context)
|
||||
this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
|
||||
}
|
||||
@@ -141,55 +161,27 @@ class BottomSheetView(
|
||||
private fun getHalfExpandedRatio(contentHeight: Float): Float =
|
||||
when {
|
||||
// Full height sheets
|
||||
contentHeight >= screenHeight -> 0.99f
|
||||
else -> this.clampRatio(this.getTargetHeight() / screenHeight)
|
||||
contentHeight >= safeScreenHeight -> 0.99f
|
||||
// Medium height sheets (>50% but <100%)
|
||||
contentHeight >= safeScreenHeight / 2 ->
|
||||
this.clampRatio(this.getTargetHeight() / safeScreenHeight)
|
||||
// Small height sheets (<50%)
|
||||
else ->
|
||||
this.clampRatio(this.getTargetHeight() / rawScreenHeight)
|
||||
}
|
||||
|
||||
private fun present() {
|
||||
if (this.isOpen || this.isOpening || this.isClosing) return
|
||||
|
||||
val contentHeight = this.getContentHeight()
|
||||
|
||||
var activityWindow: Window? = null
|
||||
var currentContext = context
|
||||
while (currentContext != null) {
|
||||
if (currentContext is android.app.Activity) {
|
||||
activityWindow = currentContext.window
|
||||
break
|
||||
}
|
||||
currentContext = (currentContext as? android.content.ContextWrapper)?.baseContext
|
||||
}
|
||||
|
||||
val originalStatusBarAppearance =
|
||||
activityWindow?.let { window ->
|
||||
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars
|
||||
}
|
||||
val originalNavBarAppearance =
|
||||
activityWindow?.let { window ->
|
||||
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightNavigationBars
|
||||
}
|
||||
|
||||
val dialog = BottomSheetDialog(context, R.style.EdgeToEdgeBottomSheetDialogTheme)
|
||||
val dialog = BottomSheetDialog(context)
|
||||
dialog.setContentView(dialogRootViewGroup)
|
||||
dialog.setCancelable(!preventDismiss)
|
||||
dialog.setDismissWithAnimation(true)
|
||||
dialog.setOnDismissListener {
|
||||
this.isClosing = true
|
||||
this.destroy()
|
||||
}
|
||||
|
||||
dialog.setOnShowListener {
|
||||
dialog.window?.let { window ->
|
||||
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
|
||||
if (originalNavBarAppearance != null) {
|
||||
insetsController.isAppearanceLightNavigationBars = originalNavBarAppearance
|
||||
}
|
||||
if (originalStatusBarAppearance != null) {
|
||||
EdgeToEdgeUtils.setLightStatusBar(window, originalStatusBarAppearance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
|
||||
bottomSheet?.let {
|
||||
it.setBackgroundColor(0)
|
||||
@@ -202,17 +194,7 @@ class BottomSheetView(
|
||||
behavior.isDraggable = true
|
||||
behavior.isHideable = true
|
||||
|
||||
if (preventExpansion) {
|
||||
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
|
||||
} else {
|
||||
behavior.maxHeight = (screenHeight - getStatusBarHeight()).toInt()
|
||||
}
|
||||
|
||||
val targetHeight = this.getTargetHeight()
|
||||
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
|
||||
val shouldBeExpanded = targetHeight >= availableHeight
|
||||
|
||||
if (shouldBeExpanded) {
|
||||
if (contentHeight >= this.safeScreenHeight || this.minHeight >= this.safeScreenHeight) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
this.selectedSnapPoint = 2
|
||||
} else {
|
||||
@@ -227,10 +209,18 @@ class BottomSheetView(
|
||||
newState: Int,
|
||||
) {
|
||||
when (newState) {
|
||||
BottomSheetBehavior.STATE_EXPANDED -> selectedSnapPoint = 2
|
||||
BottomSheetBehavior.STATE_COLLAPSED -> selectedSnapPoint = 1
|
||||
BottomSheetBehavior.STATE_HALF_EXPANDED -> selectedSnapPoint = 1
|
||||
BottomSheetBehavior.STATE_HIDDEN -> selectedSnapPoint = 0
|
||||
BottomSheetBehavior.STATE_EXPANDED -> {
|
||||
selectedSnapPoint = 2
|
||||
}
|
||||
BottomSheetBehavior.STATE_COLLAPSED -> {
|
||||
selectedSnapPoint = 1
|
||||
}
|
||||
BottomSheetBehavior.STATE_HALF_EXPANDED -> {
|
||||
selectedSnapPoint = 1
|
||||
}
|
||||
BottomSheetBehavior.STATE_HIDDEN -> {
|
||||
selectedSnapPoint = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,26 +231,9 @@ class BottomSheetView(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
this.isOpening = true
|
||||
dialog.show()
|
||||
this.dialog = dialog
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(dialogRootViewGroup) { view, insets ->
|
||||
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
|
||||
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
|
||||
val behavior = bottomSheet?.let { BottomSheetBehavior.from(it) }
|
||||
|
||||
val wasKeyboardVisible = isKeyboardVisible
|
||||
isKeyboardVisible = imeVisible
|
||||
|
||||
if (imeVisible && behavior?.state == BottomSheetBehavior.STATE_HALF_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else if (!imeVisible && wasKeyboardVisible) {
|
||||
updateLayout()
|
||||
}
|
||||
insets
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLayout() {
|
||||
@@ -273,24 +246,12 @@ class BottomSheetView(
|
||||
val currentState = behavior.state
|
||||
|
||||
val oldRatio = behavior.halfExpandedRatio
|
||||
val newRatio = getHalfExpandedRatio(contentHeight)
|
||||
var newRatio = getHalfExpandedRatio(contentHeight)
|
||||
behavior.halfExpandedRatio = newRatio
|
||||
|
||||
if (preventExpansion) {
|
||||
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
|
||||
}
|
||||
|
||||
val targetHeight = this.getTargetHeight()
|
||||
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
|
||||
val shouldBeExpanded = targetHeight >= availableHeight
|
||||
|
||||
if (isKeyboardVisible) {
|
||||
if (behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
} else if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) {
|
||||
if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else if (!shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
|
||||
} else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
} else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
@@ -318,19 +279,25 @@ class BottomSheetView(
|
||||
|
||||
private fun getTargetHeight(): Float {
|
||||
val contentHeight = this.getContentHeight()
|
||||
return when {
|
||||
contentHeight > maxHeight -> maxHeight
|
||||
contentHeight < minHeight -> minHeight
|
||||
else -> contentHeight
|
||||
}
|
||||
val height =
|
||||
if (contentHeight > maxHeight) {
|
||||
maxHeight
|
||||
} else if (contentHeight < minHeight) {
|
||||
minHeight
|
||||
} else {
|
||||
contentHeight
|
||||
}
|
||||
return height
|
||||
}
|
||||
|
||||
private fun clampRatio(ratio: Float): Float =
|
||||
when {
|
||||
ratio < 0.01 -> 0.01f
|
||||
ratio > 0.99 -> 0.99f
|
||||
else -> ratio
|
||||
private fun clampRatio(ratio: Float): Float {
|
||||
if (ratio < 0.01) {
|
||||
return 0.01f
|
||||
} else if (ratio > 0.99) {
|
||||
return 0.99f
|
||||
}
|
||||
return ratio
|
||||
}
|
||||
|
||||
private fun setDraggable(draggable: Boolean) {
|
||||
val dialog = this.dialog ?: return
|
||||
@@ -355,7 +322,9 @@ class BottomSheetView(
|
||||
// View overrides to pass to DialogRootViewGroup instead
|
||||
|
||||
override fun dispatchProvideStructure(structure: ViewStructure?) {
|
||||
if (structure == null) return
|
||||
if (structure == null) {
|
||||
return
|
||||
}
|
||||
dialogRootViewGroup.dispatchProvideStructure(structure)
|
||||
}
|
||||
|
||||
@@ -394,6 +363,7 @@ class BottomSheetView(
|
||||
// https://stackoverflow.com/questions/11862391/getheight-px-or-dpi
|
||||
fun dpToPx(dp: Float): Float {
|
||||
val displayMetrics = context.resources.displayMetrics
|
||||
return dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
|
||||
val px = dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
|
||||
return px
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,8 +52,6 @@ class DialogRootViewGroup(
|
||||
if (ReactFeatureFlags.dispatchPointerEvents) {
|
||||
jSPointerDispatcher = JSPointerDispatcher(this)
|
||||
}
|
||||
|
||||
fitsSystemWindows = false
|
||||
}
|
||||
|
||||
override fun onSizeChanged(
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="EdgeToEdgeBottomSheetDialogTheme" parent="Theme.Material3.DayNight.BottomSheetDialog">
|
||||
<!-- Enable edge-to-edge -->
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowIsFloating">false</item>
|
||||
<item name="enableEdgeToEdge">true</item>
|
||||
|
||||
<!-- Configure bottom sheet to respect system window insets -->
|
||||
<item name="bottomSheetStyle">@style/EdgeToEdgeBottomSheet</item>
|
||||
</style>
|
||||
|
||||
<style name="EdgeToEdgeBottomSheet" parent="Widget.Material3.BottomSheet">
|
||||
<item name="paddingBottomSystemWindowInsets">false</item>
|
||||
<item name="paddingLeftSystemWindowInsets">true</item>
|
||||
<item name="paddingRightSystemWindowInsets">true</item>
|
||||
<item name="paddingTopSystemWindowInsets">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -175,7 +175,6 @@ function BottomSheetNativeComponentInner({
|
||||
Platform.OS === 'android' && {
|
||||
borderTopLeftRadius: cornerRadius,
|
||||
borderTopRightRadius: cornerRadius,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
extraStyles,
|
||||
]}>
|
||||
|
||||
@@ -34,15 +34,12 @@ class NotificationPrefs(
|
||||
is Boolean -> {
|
||||
putBoolean(key, value)
|
||||
}
|
||||
|
||||
is String -> {
|
||||
putString(key, value)
|
||||
}
|
||||
|
||||
is Array<*> -> {
|
||||
putStringSet(key, value.map { it.toString() }.toSet())
|
||||
}
|
||||
|
||||
is Map<*, *> -> {
|
||||
putStringSet(key, value.map { it.toString() }.toSet())
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
|
||||
private fun handleImageIntents(
|
||||
uris: List<Uri>,
|
||||
text: String?,
|
||||
text: String?
|
||||
) {
|
||||
var allParams = ""
|
||||
|
||||
@@ -145,7 +145,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
|
||||
private fun handleVideoIntents(
|
||||
uris: List<Uri>,
|
||||
text: String?,
|
||||
text: String?
|
||||
) {
|
||||
val uri = uris[0]
|
||||
// If there is no extension for the file, substringAfterLast returns the original string - not
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.117.0",
|
||||
"version": "1.116.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -73,7 +73,7 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.18.20",
|
||||
"@atproto/api": "^0.18.15",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.6",
|
||||
@@ -166,7 +166,6 @@
|
||||
"expo-task-manager": "~14.0.9",
|
||||
"expo-updates": "~29.0.14",
|
||||
"expo-video": "~3.0.15",
|
||||
"expo-video-thumbnails": "^10.0.8",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
@@ -202,7 +201,7 @@
|
||||
"react-native-edge-to-edge": "^1.6.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-get-random-values": "~1.11.0",
|
||||
"react-native-keyboard-controller": "^1.20.7",
|
||||
"react-native-keyboard-controller": "1.18.5",
|
||||
"react-native-pager-view": "6.8.0",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
"react-native-qrcode-styled": "^0.3.3",
|
||||
@@ -229,7 +228,7 @@
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.3.208",
|
||||
"@atproto/dev-env": "^0.3.204",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/runtime": "^7.26.0",
|
||||
@@ -242,6 +241,7 @@
|
||||
"@react-native/eslint-config": "^0.81.5",
|
||||
"@react-native/typescript-config": "^0.81.5",
|
||||
"@sentry/webpack-plugin": "^3.2.2",
|
||||
"@testing-library/jest-native": "^5.4.3",
|
||||
"@testing-library/react-native": "^13.2.0",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash.chunk": "^4.2.7",
|
||||
|
||||
@@ -3,7 +3,6 @@ import '#/view/icons'
|
||||
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller'
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
SafeAreaProvider,
|
||||
@@ -15,6 +14,7 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import * as Sentry from '@sentry/react-native'
|
||||
|
||||
import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController'
|
||||
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
|
||||
import {QueryProvider} from '#/lib/react-query'
|
||||
import {s} from '#/lib/styles'
|
||||
|
||||
@@ -21,9 +21,9 @@ import * as SplashScreen from 'expo-splash-screen'
|
||||
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
// @ts-ignore
|
||||
import splashImagePointer from '../assets/splash/splash.png'
|
||||
import splashImagePointer from '../assets/splash.png'
|
||||
// @ts-ignore
|
||||
import darkSplashImagePointer from '../assets/splash/splash-dark.png'
|
||||
import darkSplashImagePointer from '../assets/splash-dark.png'
|
||||
const splashImageUri = RNImage.resolveAssetSource(splashImagePointer).uri
|
||||
const darkSplashImageUri = RNImage.resolveAssetSource(
|
||||
darkSplashImagePointer,
|
||||
@@ -146,8 +146,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
withTiming(
|
||||
1,
|
||||
{duration: 400, easing: Easing.out(Easing.cubic)},
|
||||
() => {
|
||||
'worklet'
|
||||
async () => {
|
||||
// set these values to check animation at specific point
|
||||
outroLogo.set(() =>
|
||||
withTiming(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AtpAgent,
|
||||
getAgeAssuranceRegionConfig,
|
||||
} from '@atproto/api'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
|
||||
import {persistQueryClient} from '@tanstack/react-query-persist-client'
|
||||
@@ -13,7 +14,6 @@ import debounce from 'lodash.debounce'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {
|
||||
hasSnoozedBirthdateUpdateForDid,
|
||||
@@ -45,7 +45,7 @@ const qc = new QueryClient({
|
||||
},
|
||||
})
|
||||
const persister = createAsyncStoragePersister({
|
||||
storage: createPersistedQueryStorage('age-assurance'),
|
||||
storage: AsyncStorage,
|
||||
key: 'age-assurance-query-client',
|
||||
})
|
||||
const [, cacheHydrationPromise] = persistQueryClient({
|
||||
|
||||
@@ -5,8 +5,6 @@ import {CARD_ASPECT_RATIO} from '#/lib/constants'
|
||||
import {native, platform, web} from '#/alf/util/platform'
|
||||
import * as Layout from '#/components/Layout'
|
||||
|
||||
const EXP_CURVE = 'cubic-bezier(0.16, 1, 0.3, 1)'
|
||||
|
||||
export const atoms = {
|
||||
...baseAtoms,
|
||||
|
||||
@@ -105,7 +103,7 @@ export const atoms = {
|
||||
}),
|
||||
// special composite animation for dialogs
|
||||
zoom_fade_in: web({
|
||||
animation: `zoomIn ${EXP_CURVE} 0.3s, fadeIn ${EXP_CURVE} 0.3s`,
|
||||
animation: 'zoomIn ease-out 0.1s, fadeIn ease-out 0.1s',
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -233,53 +233,6 @@ export type Events = {
|
||||
persist: boolean
|
||||
hasChanged: boolean
|
||||
}
|
||||
'composer:open': {
|
||||
logContext:
|
||||
| 'Fab'
|
||||
| 'PostReply'
|
||||
| 'QuotePost'
|
||||
| 'ProfileFeed'
|
||||
| 'Deeplink'
|
||||
| 'Other'
|
||||
isReply: boolean
|
||||
hasQuote: boolean
|
||||
hasDraft: boolean
|
||||
}
|
||||
'draft:save': {
|
||||
isNewDraft: boolean
|
||||
hasText: boolean
|
||||
hasImages: boolean
|
||||
hasVideo: boolean
|
||||
hasGif: boolean
|
||||
hasQuote: boolean
|
||||
hasLink: boolean
|
||||
postCount: number
|
||||
textLength: number
|
||||
}
|
||||
'draft:load': {
|
||||
draftAgeMs: number
|
||||
hasText: boolean
|
||||
hasImages: boolean
|
||||
hasVideo: boolean
|
||||
hasGif: boolean
|
||||
postCount: number
|
||||
}
|
||||
'draft:delete': {
|
||||
logContext: 'DraftsList'
|
||||
draftAgeMs: number
|
||||
}
|
||||
'draft:listOpen': {
|
||||
draftCount: number
|
||||
}
|
||||
'draft:post': {
|
||||
draftAgeMs: number
|
||||
wasEdited: boolean
|
||||
}
|
||||
'draft:discard': {
|
||||
logContext: 'ComposerClose' | 'BeforeDraftsList'
|
||||
hadContent: boolean
|
||||
textLength: number
|
||||
}
|
||||
|
||||
// Data events
|
||||
'account:create:begin': {}
|
||||
@@ -467,8 +420,8 @@ export type Events = {
|
||||
| 'InterstitialProfile'
|
||||
| 'Profile'
|
||||
| 'Onboarding'
|
||||
location: 'Card' | 'Profile' | 'FollowAll'
|
||||
recId?: number | string
|
||||
location: 'Card' | 'Profile'
|
||||
recId?: number
|
||||
position: number
|
||||
suggestedDid: string
|
||||
category: string | null
|
||||
@@ -479,7 +432,7 @@ export type Events = {
|
||||
| 'InterstitialDiscover'
|
||||
| 'InterstitialProfile'
|
||||
| 'Onboarding'
|
||||
recId?: number | string
|
||||
recId?: number
|
||||
position: number
|
||||
suggestedDid: string
|
||||
category: string | null
|
||||
@@ -492,7 +445,7 @@ export type Events = {
|
||||
| 'Profile'
|
||||
| 'Onboarding'
|
||||
| 'ProgressGuide'
|
||||
recId?: number | string
|
||||
recId?: number
|
||||
position: number
|
||||
suggestedDid: string
|
||||
category: string | null
|
||||
@@ -507,7 +460,7 @@ export type Events = {
|
||||
}
|
||||
'suggestedUser:dismiss': {
|
||||
logContext: 'InterstitialDiscover' | 'InterstitialProfile'
|
||||
recId?: number | string
|
||||
recId?: number
|
||||
position: number
|
||||
suggestedDid: string
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from 'react-native'
|
||||
import {
|
||||
KeyboardAwareScrollView,
|
||||
type KeyboardAwareScrollViewRef,
|
||||
useKeyboardHandler,
|
||||
useReanimatedKeyboardAnimation,
|
||||
} from 'react-native-keyboard-controller'
|
||||
@@ -24,6 +23,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useEnableKeyboardController} from '#/lib/hooks/useEnableKeyboardController'
|
||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {logger} from '#/logger'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
@@ -209,9 +209,10 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
useEnableKeyboardController(IS_IOS)
|
||||
|
||||
const [keyboardHeight, setKeyboardHeight] = React.useState(0)
|
||||
|
||||
// note: iOS-only. keyboard-controller doesn't seem to work inside the sheets on Android
|
||||
useKeyboardHandler(
|
||||
{
|
||||
onEnd: e => {
|
||||
@@ -230,6 +231,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
}
|
||||
paddingBottom = Math.max(paddingBottom, tokens.space._2xl)
|
||||
} else {
|
||||
paddingBottom += keyboardHeight
|
||||
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
|
||||
paddingBottom += insets.top
|
||||
}
|
||||
@@ -257,7 +259,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
{paddingBottom},
|
||||
contentContainerStyle,
|
||||
]}
|
||||
ref={ref as React.Ref<KeyboardAwareScrollViewRef>}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||
{...props}
|
||||
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
||||
@@ -287,6 +289,8 @@ export const InnerFlatList = React.forwardRef<
|
||||
const insets = useSafeAreaInsets()
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||
|
||||
useEnableKeyboardController(IS_IOS)
|
||||
|
||||
const onScroll = (e: ScrollEvent) => {
|
||||
'worklet'
|
||||
if (!IS_ANDROID) {
|
||||
@@ -405,7 +409,3 @@ export function Handle({
|
||||
export function Close() {
|
||||
return null
|
||||
}
|
||||
|
||||
export function Backdrop() {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
FlatList,
|
||||
type FlatListProps,
|
||||
type GestureResponderEvent,
|
||||
Pressable,
|
||||
type StyleProp,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
@@ -113,7 +113,7 @@ export function Outer({
|
||||
<Portal>
|
||||
<Context.Provider value={context}>
|
||||
<RemoveScrollBar />
|
||||
<Pressable
|
||||
<TouchableWithoutFeedback
|
||||
accessibilityHint={undefined}
|
||||
accessibilityLabel={_(msg`Close active dialog`)}
|
||||
onPress={handleBackgroundPress}>
|
||||
@@ -146,7 +146,7 @@ export function Outer({
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</TouchableWithoutFeedback>
|
||||
</Context.Provider>
|
||||
</Portal>
|
||||
)}
|
||||
@@ -304,7 +304,7 @@ export function Handle() {
|
||||
return null
|
||||
}
|
||||
|
||||
export function Backdrop() {
|
||||
function Backdrop() {
|
||||
const t = useTheme()
|
||||
const {reduceMotionEnabled} = useA11y()
|
||||
return (
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import React, {useCallback, useEffect, useRef} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
LayoutAnimationConfig,
|
||||
LinearTransition,
|
||||
} from 'react-native-reanimated'
|
||||
import Animated, {LinearTransition} from 'react-native-reanimated'
|
||||
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -27,7 +21,6 @@ import {type SeenPost} from '#/state/userActionHistory'
|
||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||
import {
|
||||
atoms as a,
|
||||
native,
|
||||
useBreakpoints,
|
||||
useTheme,
|
||||
type ViewStyleProp,
|
||||
@@ -159,7 +152,7 @@ function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
|
||||
function useExperimentalSuggestedUsersQuery() {
|
||||
const {currentAccount} = useSession()
|
||||
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
|
||||
const dids = useMemo(() => {
|
||||
const dids = React.useMemo(() => {
|
||||
const {likes, follows, followSuggestions, seen} = userActionSnapshot
|
||||
const likeDids = likes
|
||||
.map(l => new AtUri(l))
|
||||
@@ -232,54 +225,67 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
|
||||
isFetchingNextPage,
|
||||
} = useSuggestedFollowsQuery({limit: 25})
|
||||
|
||||
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
|
||||
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
|
||||
const onDismiss = useCallback((dismissedDid: string) => {
|
||||
setDismissedDids(prev => new Set(prev).add(dismissedDid))
|
||||
const onDismiss = React.useCallback((dismissedDid: string) => {
|
||||
// Start the fade animation
|
||||
setDismissingDids(prev => new Set(prev).add(dismissedDid))
|
||||
// After animation completes, actually remove from list
|
||||
setTimeout(() => {
|
||||
setDismissedDids(prev => new Set(prev).add(dismissedDid))
|
||||
setDismissingDids(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(dismissedDid)
|
||||
return next
|
||||
})
|
||||
}, DISMISS_ANIMATION_DURATION)
|
||||
}, [])
|
||||
|
||||
// Combine profiles from the actor-specific query with fallback suggestions
|
||||
const allProfiles = useMemo(() => {
|
||||
const allProfiles = React.useMemo(() => {
|
||||
const actorProfiles = data?.suggestions ?? []
|
||||
const fallbackProfiles =
|
||||
moreSuggestions?.pages.flatMap(page =>
|
||||
page.actors.map(actor => ({actor, recId: page.recId})),
|
||||
) ?? []
|
||||
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
|
||||
|
||||
// Dedupe by did, preferring actor-specific profiles
|
||||
const seen = new Set<string>()
|
||||
const combined: {actor: bsky.profile.AnyProfileView; recId?: number}[] = []
|
||||
const combined: bsky.profile.AnyProfileView[] = []
|
||||
|
||||
for (const profile of actorProfiles) {
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push({actor: profile, recId: data?.recId})
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
for (const profile of fallbackProfiles) {
|
||||
if (!seen.has(profile.actor.did) && profile.actor.did !== did) {
|
||||
seen.add(profile.actor.did)
|
||||
if (!seen.has(profile.did) && profile.did !== did) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
return combined
|
||||
}, [data?.suggestions, moreSuggestions?.pages, did, data?.recId])
|
||||
}, [data?.suggestions, moreSuggestions?.pages, did])
|
||||
|
||||
const filteredProfiles = useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
|
||||
const filteredProfiles = React.useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.did))
|
||||
}, [allProfiles, dismissedDids])
|
||||
|
||||
// Fetch more when running low
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
moderationOpts &&
|
||||
filteredProfiles.length < maxLength &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
void fetchNextPage()
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [
|
||||
filteredProfiles.length,
|
||||
@@ -295,9 +301,11 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
|
||||
isSuggestionsLoading={isSuggestionsLoading}
|
||||
profiles={filteredProfiles}
|
||||
totalProfileCount={allProfiles.length}
|
||||
recId={data?.recId}
|
||||
error={error}
|
||||
viewContext="profile"
|
||||
onDismiss={onDismiss}
|
||||
dismissingDids={dismissingDids}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -319,36 +327,46 @@ export function SuggestedFollowsHome() {
|
||||
error: suggestionsError,
|
||||
} = useSuggestedFollowsQuery({limit: 25})
|
||||
|
||||
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
|
||||
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
|
||||
const onDismiss = useCallback((did: string) => {
|
||||
setDismissedDids(prev => new Set(prev).add(did))
|
||||
const onDismiss = React.useCallback((did: string) => {
|
||||
// Start the fade animation
|
||||
setDismissingDids(prev => new Set(prev).add(did))
|
||||
// After animation completes, actually remove from list
|
||||
setTimeout(() => {
|
||||
setDismissedDids(prev => new Set(prev).add(did))
|
||||
setDismissingDids(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(did)
|
||||
return next
|
||||
})
|
||||
}, DISMISS_ANIMATION_DURATION)
|
||||
}, [])
|
||||
|
||||
// Combine profiles from experimental query with paginated suggestions
|
||||
const allProfiles = useMemo(() => {
|
||||
const allProfiles = React.useMemo(() => {
|
||||
const fallbackProfiles =
|
||||
moreSuggestions?.pages.flatMap(page =>
|
||||
page.actors.map(actor => ({actor, recId: page.recId})),
|
||||
) ?? []
|
||||
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
|
||||
|
||||
// Dedupe by did, preferring experimental profiles
|
||||
const seen = new Set<string>()
|
||||
const combined: Array<{
|
||||
actor: bsky.profile.AnyProfileView
|
||||
recId?: number
|
||||
}> = []
|
||||
const combined: bsky.profile.AnyProfileView[] = []
|
||||
|
||||
for (const profile of experimentalProfiles) {
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push({actor: profile, recId: undefined})
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
for (const profile of fallbackProfiles) {
|
||||
if (!seen.has(profile.actor.did)) {
|
||||
seen.add(profile.actor.did)
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
@@ -356,19 +374,19 @@ export function SuggestedFollowsHome() {
|
||||
return combined
|
||||
}, [experimentalProfiles, moreSuggestions?.pages])
|
||||
|
||||
const filteredProfiles = useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
|
||||
const filteredProfiles = React.useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.did))
|
||||
}, [allProfiles, dismissedDids])
|
||||
|
||||
// Fetch more when running low
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
moderationOpts &&
|
||||
filteredProfiles.length < maxLength &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
void fetchNextPage()
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [
|
||||
filteredProfiles.length,
|
||||
@@ -387,6 +405,7 @@ export function SuggestedFollowsHome() {
|
||||
error={experimentalError || suggestionsError}
|
||||
viewContext="feed"
|
||||
onDismiss={onDismiss}
|
||||
dismissingDids={dismissingDids}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -396,14 +415,18 @@ export function ProfileGrid({
|
||||
error,
|
||||
profiles,
|
||||
totalProfileCount,
|
||||
recId,
|
||||
viewContext = 'feed',
|
||||
onDismiss,
|
||||
dismissingDids,
|
||||
isVisible = true,
|
||||
}: {
|
||||
isSuggestionsLoading: boolean
|
||||
profiles: {actor: bsky.profile.AnyProfileView; recId?: number}[]
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
totalProfileCount?: number
|
||||
recId?: number
|
||||
error: Error | null
|
||||
dismissingDids?: Set<string>
|
||||
viewContext: 'profile' | 'profileHeader' | 'feed'
|
||||
onDismiss?: (did: string) => void
|
||||
isVisible?: boolean
|
||||
@@ -440,18 +463,18 @@ export function ProfileGrid({
|
||||
|
||||
const profilesToShow = profiles.slice(0, maxLength)
|
||||
profilesToShow.forEach((profile, index) => {
|
||||
if (!seenProfilesRef.current.has(profile.actor.did)) {
|
||||
seenProfilesRef.current.add(profile.actor.did)
|
||||
if (!seenProfilesRef.current.has(profile.did)) {
|
||||
seenProfilesRef.current.add(profile.did)
|
||||
ax.metric('suggestedUser:seen', {
|
||||
logContext,
|
||||
recId: profile.recId,
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.actor.did,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [ax, isLoading, error, profiles, maxLength, logContext])
|
||||
}, [ax, isLoading, error, profiles, maxLength, logContext, recId])
|
||||
|
||||
// For profile header, fire when isVisible becomes true
|
||||
useEffect(() => {
|
||||
@@ -517,15 +540,8 @@ export function ProfileGrid({
|
||||
? null
|
||||
: profiles.slice(0, maxLength).map((profile, index) => (
|
||||
<Animated.View
|
||||
key={profile.actor.did}
|
||||
layout={native(
|
||||
LinearTransition.delay(DISMISS_ANIMATION_DURATION).easing(
|
||||
Easing.out(Easing.exp),
|
||||
),
|
||||
)}
|
||||
exiting={FadeOut.duration(DISMISS_ANIMATION_DURATION)}
|
||||
// for web, as the cards are static, not in a list
|
||||
entering={web(FadeIn.delay(DISMISS_ANIMATION_DURATION * 2))}
|
||||
key={profile.did}
|
||||
layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)}
|
||||
style={[
|
||||
a.flex_1,
|
||||
gtMobile &&
|
||||
@@ -534,17 +550,22 @@ export function ProfileGrid({
|
||||
a.flex_grow,
|
||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||
]),
|
||||
{
|
||||
opacity: dismissingDids?.has(profile.did) ? 0 : 1,
|
||||
transitionProperty: 'opacity',
|
||||
transitionDuration: `${DISMISS_ANIMATION_DURATION}ms`,
|
||||
},
|
||||
]}>
|
||||
<ProfileCard.Link
|
||||
profile={profile.actor}
|
||||
profile={profile}
|
||||
onPress={() => {
|
||||
ax.metric('suggestedUser:press', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
recId: profile.recId,
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.actor.did,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}}
|
||||
@@ -560,14 +581,14 @@ export function ProfileGrid({
|
||||
label={_(msg`Dismiss this suggestion`)}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
onDismiss(profile.actor.did)
|
||||
onDismiss(profile.did)
|
||||
ax.metric('suggestedUser:dismiss', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
position: index,
|
||||
suggestedDid: profile.actor.did,
|
||||
recId: profile.recId,
|
||||
suggestedDid: profile.did,
|
||||
recId,
|
||||
})
|
||||
}}
|
||||
style={[
|
||||
@@ -600,18 +621,18 @@ export function ProfileGrid({
|
||||
a.mb_auto,
|
||||
]}>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile.actor}
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
disabledPreview
|
||||
size={88}
|
||||
/>
|
||||
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
|
||||
<ProfileCard.Name
|
||||
profile={profile.actor}
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.Description
|
||||
profile={profile.actor}
|
||||
profile={profile}
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
@@ -623,7 +644,7 @@ export function ProfileGrid({
|
||||
</View>
|
||||
|
||||
<ProfileCard.FollowButton
|
||||
profile={profile.actor}
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="FeedInterstitial"
|
||||
withIcon={false}
|
||||
@@ -634,9 +655,9 @@ export function ProfileGrid({
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
location: 'Card',
|
||||
recId: profile.recId,
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.actor.did,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}}
|
||||
@@ -674,7 +695,11 @@ export function ProfileGrid({
|
||||
]}
|
||||
pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
|
||||
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}>
|
||||
<Trans>Suggested for you</Trans>
|
||||
{isFeedContext ? (
|
||||
<Trans>Suggested for you</Trans>
|
||||
) : (
|
||||
<Trans>Similar accounts</Trans>
|
||||
)}
|
||||
</Text>
|
||||
{!isProfileHeaderContext && (
|
||||
<Button
|
||||
@@ -705,37 +730,35 @@ export function ProfileGrid({
|
||||
|
||||
<FollowDialogWithoutGuide control={followDialogControl} />
|
||||
|
||||
<LayoutAnimationConfig skipExiting skipEntering>
|
||||
{gtMobile ? (
|
||||
<View style={[a.p_lg, a.pt_md]}>
|
||||
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
|
||||
{content}
|
||||
</View>
|
||||
{gtMobile ? (
|
||||
<View style={[a.p_lg, a.pt_md]}>
|
||||
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
|
||||
{content}
|
||||
</View>
|
||||
) : (
|
||||
<BlockDrawerGesture>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||
decelerationRate="fast">
|
||||
{content}
|
||||
</View>
|
||||
) : (
|
||||
<BlockDrawerGesture>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||
decelerationRate="fast">
|
||||
{content}
|
||||
|
||||
{!isProfileHeaderContext && (
|
||||
<SeeMoreSuggestedProfilesCard
|
||||
onPress={() => {
|
||||
followDialogControl.open()
|
||||
ax.metric('suggestedUser:seeMore', {
|
||||
logContext: 'Explore',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
</BlockDrawerGesture>
|
||||
)}
|
||||
</LayoutAnimationConfig>
|
||||
{!isProfileHeaderContext && (
|
||||
<SeeMoreSuggestedProfilesCard
|
||||
onPress={() => {
|
||||
followDialogControl.open()
|
||||
ax.metric('suggestedUser:seeMore', {
|
||||
logContext: 'Explore',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
</BlockDrawerGesture>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -776,7 +799,7 @@ export function SuggestedFeeds() {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const feeds = useMemo(() => {
|
||||
const feeds = React.useMemo(() => {
|
||||
const items: AppBskyFeedDefs.GeneratorView[] = []
|
||||
|
||||
if (!data) return items
|
||||
|
||||
@@ -157,7 +157,7 @@ export function Link({
|
||||
to={{
|
||||
screen: 'Profile',
|
||||
params: {
|
||||
name: labeler.creator.did,
|
||||
name: labeler.creator.handle,
|
||||
},
|
||||
}}
|
||||
label={_(
|
||||
|
||||
@@ -50,11 +50,7 @@ export function Embed({
|
||||
} else if (e.type === 'video') {
|
||||
return (
|
||||
<Outer style={style}>
|
||||
{e.view.presentation === 'gif' ? (
|
||||
<GifItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
|
||||
) : (
|
||||
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
|
||||
)}
|
||||
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
|
||||
</Outer>
|
||||
)
|
||||
} else if (
|
||||
@@ -85,29 +81,11 @@ export function ImageItem({
|
||||
alt,
|
||||
children,
|
||||
}: {
|
||||
thumbnail?: string
|
||||
thumbnail: string
|
||||
alt?: string
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (!thumbnail) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{backgroundColor: 'black'},
|
||||
a.flex_1,
|
||||
a.aspect_square,
|
||||
{maxWidth: 100},
|
||||
a.rounded_xs,
|
||||
]}
|
||||
accessibilityLabel={alt}
|
||||
accessibilityHint="">
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth: 100}]}>
|
||||
<Image
|
||||
@@ -125,7 +103,7 @@ export function ImageItem({
|
||||
)
|
||||
}
|
||||
|
||||
export function GifItem({thumbnail, alt}: {thumbnail?: string; alt?: string}) {
|
||||
export function GifItem({thumbnail, alt}: {thumbnail: string; alt?: string}) {
|
||||
return (
|
||||
<ImageItem thumbnail={thumbnail} alt={alt}>
|
||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
@@ -147,6 +125,21 @@ export function VideoItem({
|
||||
thumbnail?: string
|
||||
alt?: string
|
||||
}) {
|
||||
if (!thumbnail) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{backgroundColor: 'black'},
|
||||
a.flex_1,
|
||||
a.aspect_square,
|
||||
{maxWidth: 100},
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
]}>
|
||||
<PlayButtonIcon size={24} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ImageItem thumbnail={thumbnail} alt={alt}>
|
||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
@@ -163,7 +156,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
left: 5,
|
||||
right: 5,
|
||||
bottom: 5,
|
||||
zIndex: 2,
|
||||
},
|
||||
|
||||
@@ -1,17 +1,76 @@
|
||||
import {useRef, useState} from 'react'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import React from 'react'
|
||||
import {
|
||||
Pressable,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {type EmbedPlayerParams} from '#/lib/strings/embed-player'
|
||||
import {useAutoplayDisabled} from '#/state/preferences'
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Fill} from '#/components/Fill'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
|
||||
import {type GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
|
||||
import {GifPresentationControls} from '../VideoEmbed/GifPresentationControls'
|
||||
|
||||
function PlaybackControls({
|
||||
onPress,
|
||||
isPlaying,
|
||||
isLoaded,
|
||||
}: {
|
||||
onPress: () => void
|
||||
isPlaying: boolean
|
||||
isLoaded: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={_(msg`Plays or pauses the GIF`)}
|
||||
accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
!isLoaded && a.border,
|
||||
t.atoms.border_contrast_medium,
|
||||
a.inset_0,
|
||||
a.w_full,
|
||||
a.h_full,
|
||||
{
|
||||
zIndex: 2,
|
||||
backgroundColor: !isLoaded
|
||||
? t.atoms.bg_contrast_25.backgroundColor
|
||||
: undefined,
|
||||
},
|
||||
]}
|
||||
onPress={onPress}>
|
||||
{!isLoaded ? (
|
||||
<View>
|
||||
<View style={[a.align_center, a.justify_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
</View>
|
||||
) : !isPlaying ? (
|
||||
<PlayButtonIcon />
|
||||
) : undefined}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export function GifEmbed({
|
||||
params,
|
||||
@@ -32,9 +91,9 @@ export function GifEmbed({
|
||||
const {_} = useLingui()
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
|
||||
const playerRef = useRef<GifView>(null)
|
||||
const playerRef = React.useRef<GifView>(null)
|
||||
|
||||
const [playerState, setPlayerState] = useState<{
|
||||
const [playerState, setPlayerState] = React.useState<{
|
||||
isPlaying: boolean
|
||||
isLoaded: boolean
|
||||
}>({
|
||||
@@ -42,18 +101,24 @@ export function GifEmbed({
|
||||
isLoaded: false,
|
||||
})
|
||||
|
||||
const onPlayerStateChange = (e: GifViewStateChangeEvent) => {
|
||||
setPlayerState(e.nativeEvent)
|
||||
}
|
||||
const onPlayerStateChange = React.useCallback(
|
||||
(e: GifViewStateChangeEvent) => {
|
||||
setPlayerState(e.nativeEvent)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const onPress = () => {
|
||||
void playerRef.current?.toggleAsync()
|
||||
}
|
||||
const onPress = React.useCallback(() => {
|
||||
playerRef.current?.toggleAsync()
|
||||
}, [])
|
||||
|
||||
let aspectRatio = 1
|
||||
if (params.dimensions) {
|
||||
const ratio = params.dimensions.width / params.dimensions.height
|
||||
aspectRatio = clamp(ratio, 0.75, 4)
|
||||
aspectRatio = clamp(
|
||||
params.dimensions.width / params.dimensions.height,
|
||||
0.75,
|
||||
4,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -61,6 +126,8 @@ export function GifEmbed({
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
{backgroundColor: t.palette.black},
|
||||
{aspectRatio},
|
||||
style,
|
||||
@@ -78,12 +145,10 @@ export function GifEmbed({
|
||||
right: -2,
|
||||
},
|
||||
]}>
|
||||
<MediaInsetBorder />
|
||||
<GifPresentationControls
|
||||
<PlaybackControls
|
||||
onPress={onPress}
|
||||
isPlaying={playerState.isPlaying}
|
||||
isLoading={!playerState.isLoaded}
|
||||
altText={!hideAlt && isPreferredAltText ? altText : undefined}
|
||||
isLoaded={playerState.isLoaded}
|
||||
/>
|
||||
<GifView
|
||||
source={params.playerUri}
|
||||
@@ -105,7 +170,66 @@ export function GifEmbed({
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{!hideAlt && isPreferredAltText && <AltText text={altText} />}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function AltText({text}: {text: string}) {
|
||||
const control = Prompt.usePromptControl()
|
||||
const largeAltBadge = useLargeAltBadgeEnabled()
|
||||
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
testID="altTextButton"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Show alt text`)}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_20}
|
||||
onPress={control.open}
|
||||
style={styles.altContainer}>
|
||||
<Text
|
||||
style={[styles.alt, largeAltBadge && a.text_xs]}
|
||||
accessible={false}>
|
||||
<Trans>ALT</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Prompt.Outer control={control}>
|
||||
<Prompt.TitleText>
|
||||
<Trans>Alt Text</Trans>
|
||||
</Prompt.TitleText>
|
||||
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action
|
||||
onPress={() => control.close()}
|
||||
cta={_(msg`Close`)}
|
||||
color="secondary"
|
||||
/>
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
altContainer: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: IS_WEB ? 8 : 6,
|
||||
paddingVertical: IS_WEB ? 6 : 3,
|
||||
position: 'absolute',
|
||||
// Related to margin/gap hack. This keeps the alt label in the same position
|
||||
// on all platforms
|
||||
right: IS_WEB ? 8 : 5,
|
||||
bottom: IS_WEB ? 8 : 5,
|
||||
zIndex: 2,
|
||||
},
|
||||
alt: {
|
||||
color: 'white',
|
||||
fontSize: IS_WEB ? 10 : 7,
|
||||
fontWeight: '600',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Fill} from '#/components/Fill'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
|
||||
export function GifPresentationControls({
|
||||
onPress,
|
||||
isPlaying,
|
||||
isLoading,
|
||||
altText,
|
||||
}: {
|
||||
onPress: () => void
|
||||
isPlaying: boolean
|
||||
isLoading?: boolean
|
||||
altText?: string
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label={isPlaying ? _(msg`Pause GIF`) : _(msg`Play GIF`)}
|
||||
accessibilityHint={_(msg`Plays or pauses the GIF`)}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.inset_0,
|
||||
{zIndex: 2},
|
||||
]}
|
||||
onPress={onPress}>
|
||||
{isLoading ? (
|
||||
<View style={[a.align_center, a.justify_center]}>
|
||||
<ActivityIndicator size="large" color="white" />
|
||||
</View>
|
||||
) : !isPlaying ? (
|
||||
<PlayButtonIcon />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Button>
|
||||
{!isPlaying && (
|
||||
<Fill
|
||||
style={[
|
||||
t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
|
||||
{
|
||||
opacity: 0.2,
|
||||
zIndex: 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.gifBadgeContainer}>
|
||||
<Text style={[{color: 'white'}, a.font_bold, a.text_xs]}>
|
||||
<Trans>GIF</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
{altText && <AltBadge text={altText} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AltBadge({text}: {text: string}) {
|
||||
const control = Prompt.usePromptControl()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
testID="altTextButton"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Show alt text`)}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_20}
|
||||
onPress={control.open}
|
||||
style={styles.altBadgeContainer}>
|
||||
<Text
|
||||
style={[{color: 'white'}, a.font_bold, a.text_xs]}
|
||||
accessible={false}>
|
||||
<Trans>ALT</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Prompt.Outer control={control}>
|
||||
<Prompt.Content>
|
||||
<Prompt.TitleText>
|
||||
<Trans>Alt Text</Trans>
|
||||
</Prompt.TitleText>
|
||||
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
|
||||
</Prompt.Content>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action
|
||||
onPress={() => control.close()}
|
||||
cta={_(msg`Close`)}
|
||||
color="secondary"
|
||||
/>
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
gifBadgeContainer: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
left: 6,
|
||||
bottom: 6,
|
||||
zIndex: 2,
|
||||
},
|
||||
altBadgeContainer: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
right: 6,
|
||||
bottom: 6,
|
||||
zIndex: 2,
|
||||
},
|
||||
})
|
||||
@@ -15,7 +15,6 @@ import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
||||
import {GifPresentationControls} from '../GifPresentationControls'
|
||||
import {TimeIndicator} from './TimeIndicator'
|
||||
|
||||
export function VideoEmbedInnerNative({
|
||||
@@ -51,14 +50,12 @@ export function VideoEmbedInnerNative({
|
||||
throw new Error(error)
|
||||
}
|
||||
|
||||
const isGif = embed.presentation === 'gif'
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, a.relative]}>
|
||||
<BlueskyVideoView
|
||||
url={embed.playlist}
|
||||
autoplay={!autoplayDisabled && !isWithinMessage}
|
||||
beginMuted={isGif || autoplayDisabled ? false : muted}
|
||||
beginMuted={autoplayDisabled ? false : muted}
|
||||
style={[a.rounded_sm]}
|
||||
onActiveChange={e => {
|
||||
setIsActive(e.nativeEvent.isActive)
|
||||
@@ -85,36 +82,25 @@ export function VideoEmbedInnerNative({
|
||||
}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
{isGif ? (
|
||||
<GifPresentationControls
|
||||
onPress={() => {
|
||||
videoRef.current?.togglePlayback()
|
||||
}}
|
||||
isPlaying={isPlaying}
|
||||
isLoading={false}
|
||||
altText={embed.alt}
|
||||
/>
|
||||
) : (
|
||||
<VideoPresentationControls
|
||||
enterFullscreen={() => {
|
||||
videoRef.current?.enterFullscreen(true)
|
||||
}}
|
||||
toggleMuted={() => {
|
||||
videoRef.current?.toggleMuted()
|
||||
}}
|
||||
togglePlayback={() => {
|
||||
videoRef.current?.togglePlayback()
|
||||
}}
|
||||
isPlaying={isPlaying}
|
||||
timeRemaining={timeRemaining}
|
||||
/>
|
||||
)}
|
||||
<VideoControls
|
||||
enterFullscreen={() => {
|
||||
videoRef.current?.enterFullscreen(true)
|
||||
}}
|
||||
toggleMuted={() => {
|
||||
videoRef.current?.toggleMuted()
|
||||
}}
|
||||
togglePlayback={() => {
|
||||
videoRef.current?.togglePlayback()
|
||||
}}
|
||||
isPlaying={isPlaying}
|
||||
timeRemaining={timeRemaining}
|
||||
/>
|
||||
<MediaInsetBorder />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoPresentationControls({
|
||||
function VideoControls({
|
||||
enterFullscreen,
|
||||
toggleMuted,
|
||||
togglePlayback,
|
||||
|
||||
@@ -21,7 +21,7 @@ export function VideoEmbedInnerWeb({
|
||||
active: boolean
|
||||
setActive: () => void
|
||||
onScreen: boolean
|
||||
lastKnownTime: React.RefObject<number | undefined>
|
||||
lastKnownTime: React.MutableRefObject<number | undefined>
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
@@ -37,7 +37,7 @@ export function VideoEmbedInnerWeb({
|
||||
throw error
|
||||
}
|
||||
|
||||
const {hlsRef, loop} = useHLS({
|
||||
const hlsRef = useHLS({
|
||||
playlist: embed.playlist,
|
||||
setHasSubtitleTrack,
|
||||
setError,
|
||||
@@ -64,12 +64,11 @@ export function VideoEmbedInnerWeb({
|
||||
style={{width: '100%', height: '100%', objectFit: 'contain'}}
|
||||
playsInline
|
||||
preload="none"
|
||||
muted={embed.presentation === 'gif' || !focused}
|
||||
muted={!focused}
|
||||
aria-labelledby={embed.alt ? figId : undefined}
|
||||
onTimeUpdate={e => {
|
||||
lastKnownTime.current = e.currentTarget.currentTime
|
||||
}}
|
||||
loop={loop}
|
||||
/>
|
||||
{embed.alt && (
|
||||
<figcaption
|
||||
@@ -100,8 +99,6 @@ export function VideoEmbedInnerWeb({
|
||||
onScreen={onScreen}
|
||||
fullscreenRef={containerRef}
|
||||
hasSubtitleTrack={hasSubtitleTrack}
|
||||
isGif={embed.presentation === 'gif'}
|
||||
altText={embed.alt}
|
||||
/>
|
||||
</div>
|
||||
</View>
|
||||
@@ -195,6 +192,29 @@ function useHLS({
|
||||
},
|
||||
)
|
||||
|
||||
const flushOnLoop = useNonReactiveCallback(() => {
|
||||
if (!Hls) return
|
||||
if (!hlsRef.current) return
|
||||
const hls = hlsRef.current
|
||||
// the above callback will catch most stale frags, but there's a corner case -
|
||||
// if there's only one segment in the video, it won't get flushed because it avoids
|
||||
// flushing the currently active segment. Therefore, we have to catch it when we loop
|
||||
if (
|
||||
hls.nextAutoLevel > 0 &&
|
||||
lowQualityFragments.length === 1 &&
|
||||
lowQualityFragments[0].start === 0
|
||||
) {
|
||||
const lowQualFrag = lowQualityFragments[0]
|
||||
|
||||
hls.trigger(Hls.Events.BUFFER_FLUSHING, {
|
||||
startOffset: lowQualFrag.start,
|
||||
endOffset: lowQualFrag.end,
|
||||
type: 'video',
|
||||
})
|
||||
setLowQualityFragments([])
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return
|
||||
if (!Hls) return
|
||||
@@ -222,6 +242,20 @@ function useHLS({
|
||||
hls.attachMedia(videoRef.current)
|
||||
hls.loadSource(playlist)
|
||||
|
||||
// manually loop, so if we've flushed the first buffer it doesn't get confused
|
||||
const abortController = new AbortController()
|
||||
const {signal} = abortController
|
||||
const videoNode = videoRef.current
|
||||
videoNode.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
flushOnLoop()
|
||||
videoNode.currentTime = 0
|
||||
videoNode.play()
|
||||
},
|
||||
{signal},
|
||||
)
|
||||
|
||||
hls.on(Hls.Events.FRAG_LOADED, () => {
|
||||
BandwidthEstimate.set(hls.bandwidthEstimate)
|
||||
})
|
||||
@@ -259,65 +293,17 @@ function useHLS({
|
||||
hlsRef.current = undefined
|
||||
hls.detachMedia()
|
||||
hls.destroy()
|
||||
}
|
||||
}, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange, Hls])
|
||||
|
||||
const flushOnLoop = useNonReactiveCallback(() => {
|
||||
if (!Hls) return
|
||||
if (!hlsRef.current) return
|
||||
const hls = hlsRef.current
|
||||
// `handleFragChange` will catch most stale frags, but there's a corner case -
|
||||
// if there's only one segment in the video, it won't get flushed because it avoids
|
||||
// flushing the currently active segment. Therefore, we have to catch it when we loop
|
||||
if (
|
||||
hls.nextAutoLevel > 0 &&
|
||||
lowQualityFragments.length === 1 &&
|
||||
lowQualityFragments[0].start === 0
|
||||
) {
|
||||
const lowQualFrag = lowQualityFragments[0]
|
||||
|
||||
hls.trigger(Hls.Events.BUFFER_FLUSHING, {
|
||||
startOffset: lowQualFrag.start,
|
||||
endOffset: lowQualFrag.end,
|
||||
type: 'video',
|
||||
})
|
||||
setLowQualityFragments([])
|
||||
}
|
||||
})
|
||||
|
||||
// manually loop, so if we've flushed the first buffer it doesn't get confused
|
||||
const hasLowQualityFragmentAtStart = lowQualityFragments.some(
|
||||
frag => frag.start === 0,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return
|
||||
|
||||
// use `loop` prop on `<video>` element if the starting frag is high quality.
|
||||
// otherwise, we need to do it with an event listener as we may need to manually flush the frag
|
||||
if (!hasLowQualityFragmentAtStart) return
|
||||
|
||||
const abortController = new AbortController()
|
||||
const {signal} = abortController
|
||||
const videoNode = videoRef.current
|
||||
videoNode.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
flushOnLoop()
|
||||
videoNode.currentTime = 0
|
||||
const maybePromise = videoNode.play() as Promise<void> | undefined
|
||||
if (maybePromise) {
|
||||
maybePromise.catch(() => {})
|
||||
}
|
||||
},
|
||||
{signal},
|
||||
)
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}, [videoRef, flushOnLoop, hasLowQualityFragmentAtStart])
|
||||
}, [
|
||||
playlist,
|
||||
setError,
|
||||
setHasSubtitleTrack,
|
||||
videoRef,
|
||||
handleFragChange,
|
||||
flushOnLoop,
|
||||
Hls,
|
||||
])
|
||||
|
||||
return {
|
||||
hlsRef,
|
||||
loop: !hasLowQualityFragmentAtStart,
|
||||
}
|
||||
return hlsRef
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_WEB_MOBILE_IOS, IS_WEB_TOUCH_DEVICE} from '#/env'
|
||||
import {GifPresentationControls} from '../../GifPresentationControls'
|
||||
import {TimeIndicator} from '../TimeIndicator'
|
||||
import {ControlButton} from './ControlButton'
|
||||
import {Scrubber} from './Scrubber'
|
||||
@@ -45,8 +44,6 @@ export function Controls({
|
||||
fullscreenRef,
|
||||
hlsLoading,
|
||||
hasSubtitleTrack,
|
||||
isGif,
|
||||
altText,
|
||||
}: {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>
|
||||
hlsRef: React.RefObject<Hls | undefined | null>
|
||||
@@ -58,8 +55,6 @@ export function Controls({
|
||||
fullscreenRef: React.RefObject<HTMLDivElement | null>
|
||||
hlsLoading: boolean
|
||||
hasSubtitleTrack: boolean
|
||||
isGif: boolean
|
||||
altText?: string
|
||||
}) {
|
||||
const {
|
||||
play,
|
||||
@@ -130,14 +125,13 @@ export function Controls({
|
||||
const autoplayDisabled = useAutoplayDisabled() || isWithinMessage
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
// GIFs play immediately, videos wait until onScreen
|
||||
if (onScreen || isGif) {
|
||||
if (onScreen) {
|
||||
if (!autoplayDisabled) play()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
}, [onScreen, pause, active, play, autoplayDisabled, isGif])
|
||||
}, [onScreen, pause, active, play, autoplayDisabled])
|
||||
|
||||
// use minimal quality when not focused
|
||||
useEffect(() => {
|
||||
@@ -293,17 +287,6 @@ export function Controls({
|
||||
((focused || autoplayDisabled) && !playing) ||
|
||||
(interactingViaKeypress ? hasFocus : hovered)
|
||||
|
||||
if (isGif) {
|
||||
return (
|
||||
<GifPresentationControls
|
||||
isPlaying={playing}
|
||||
isLoading={showSpinner}
|
||||
onPress={onPressPlayPause}
|
||||
altText={altText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -329,13 +312,13 @@ export function Controls({
|
||||
onPointerEnter={onPointerMoveEmptySpace}
|
||||
onPointerMove={onPointerMoveEmptySpace}
|
||||
onPointerLeave={onPointerLeaveEmptySpace}
|
||||
accessibilityLabel={
|
||||
accessibilityLabel={_(
|
||||
!focused
|
||||
? _(msg`Unmute video`)
|
||||
? msg`Unmute video`
|
||||
: playing
|
||||
? _(msg`Pause video`)
|
||||
: _(msg`Play video`)
|
||||
}
|
||||
? msg`Pause video`
|
||||
: msg`Play video`,
|
||||
)}
|
||||
accessibilityHint=""
|
||||
style={[
|
||||
a.flex_1,
|
||||
|
||||
@@ -6,12 +6,11 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {atoms as a, platform} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
|
||||
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
import {GifPresentationControls} from './GifPresentationControls'
|
||||
import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
|
||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
@@ -102,40 +101,33 @@ function InnerWrapper({embed}: Props) {
|
||||
{
|
||||
backgroundColor: 'transparent', // If you don't add `backgroundColor` to the styles here,
|
||||
// the play button won't show up on the first render on android 🥴😮💨
|
||||
display: showOverlay ? 'flex' : 'none',
|
||||
},
|
||||
platform({
|
||||
android: {display: showOverlay ? 'flex' : 'none'},
|
||||
ios: {zIndex: showOverlay ? 1 : -1},
|
||||
}),
|
||||
]}
|
||||
cachePolicy="memory-disk" // Preferring memory cache helps to avoid flicker when re-displaying on android
|
||||
>
|
||||
{showOverlay &&
|
||||
(embed.presentation === 'gif' ? (
|
||||
<GifPresentationControls
|
||||
isPlaying={false}
|
||||
isLoading={showSpinner}
|
||||
onPress={() => {
|
||||
ref.current?.togglePlayback()
|
||||
}}
|
||||
altText={embed.alt}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
style={[a.flex_1, a.align_center, a.justify_center]}
|
||||
onPress={() => {
|
||||
ref.current?.togglePlayback()
|
||||
}}
|
||||
label={_(msg`Play video`)}>
|
||||
{showSpinner ? (
|
||||
<View style={[a.align_center, a.justify_center]}>
|
||||
<ActivityIndicator size="large" color="white" />
|
||||
</View>
|
||||
) : (
|
||||
<PlayButtonIcon />
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
{showOverlay && (
|
||||
<Button
|
||||
style={[a.flex_1, a.align_center, a.justify_center]}
|
||||
onPress={() => {
|
||||
ref.current?.togglePlayback()
|
||||
}}
|
||||
label={_(msg`Play video`)}>
|
||||
{showSpinner ? (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.p_xs,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
]}>
|
||||
<ActivityIndicator size="large" color="white" />
|
||||
</View>
|
||||
) : (
|
||||
<PlayButtonIcon />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</ImageBackground>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -26,25 +26,15 @@ import {IS_WEB_FIREFOX} from '#/env'
|
||||
import {useActiveVideoWeb} from './ActiveVideoWebContext'
|
||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||
const t = useTheme()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const {
|
||||
active: activeFromContext,
|
||||
setActive,
|
||||
sendPosition,
|
||||
currentActiveView,
|
||||
} = useActiveVideoWeb()
|
||||
const {active, setActive, sendPosition, currentActiveView} =
|
||||
useActiveVideoWeb()
|
||||
const [onScreen, setOnScreen] = useState(false)
|
||||
const [isFullscreen] = useFullscreen()
|
||||
const lastKnownTime = useRef<number | undefined>(undefined)
|
||||
|
||||
const isGif = embed.presentation === 'gif'
|
||||
// GIFs don't participate in the "one video at a time" system
|
||||
const active = isGif || activeFromContext
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
if (isFullscreen && !IS_WEB_FIREFOX) return
|
||||
@@ -53,18 +43,15 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
setOnScreen(entry.isIntersecting)
|
||||
// GIFs don't send position - they don't compete to be the active video
|
||||
if (!isGif) {
|
||||
sendPosition(
|
||||
entry.boundingClientRect.y + entry.boundingClientRect.height / 2,
|
||||
)
|
||||
}
|
||||
sendPosition(
|
||||
entry.boundingClientRect.y + entry.boundingClientRect.height / 2,
|
||||
)
|
||||
},
|
||||
{threshold: 0.5},
|
||||
)
|
||||
observer.observe(ref.current)
|
||||
return () => observer.disconnect()
|
||||
}, [sendPosition, isFullscreen, isGif])
|
||||
}, [sendPosition, isFullscreen])
|
||||
|
||||
const [key, setKey] = useState(0)
|
||||
const renderError = useCallback(
|
||||
@@ -120,7 +107,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||
return (
|
||||
<View style={[a.pt_xs]}>
|
||||
<ViewportObserver
|
||||
sendPosition={isGif ? noop : sendPosition}
|
||||
sendPosition={sendPosition}
|
||||
isAnyViewActive={currentActiveView !== null}>
|
||||
<ConstrainedImage
|
||||
fullBleed
|
||||
|
||||
@@ -185,7 +185,6 @@ let PostControls = ({
|
||||
openComposer({
|
||||
quote: post,
|
||||
onPost: onPostReply,
|
||||
logContext: 'QuotePost',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {type ModerationOpts} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useActorSearch} from '#/state/queries/actor-search'
|
||||
@@ -208,15 +207,6 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
hasSearchText &&
|
||||
!isFetchingSearchResults &&
|
||||
!_items.length &&
|
||||
!isSearchResultsError
|
||||
) {
|
||||
_items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
|
||||
}
|
||||
|
||||
return _items
|
||||
}, [
|
||||
_,
|
||||
@@ -229,9 +219,17 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
currentAccount?.did,
|
||||
hasSearchText,
|
||||
resultsKey,
|
||||
isSearchResultsError,
|
||||
])
|
||||
|
||||
if (
|
||||
searchText &&
|
||||
!isFetchingSearchResults &&
|
||||
!items.length &&
|
||||
!isSearchResultsError
|
||||
) {
|
||||
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
|
||||
}
|
||||
|
||||
const renderItems = useCallback(
|
||||
({item, index}: {item: Item; index: number}) => {
|
||||
switch (item.type) {
|
||||
@@ -264,7 +262,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
const selectedInterestRef = useRef(selectedInterest)
|
||||
selectedInterestRef.current = selectedInterest
|
||||
|
||||
const onViewableItemsChanged = useNonReactiveCallback(
|
||||
const onViewableItemsChanged = useRef(
|
||||
({viewableItems}: {viewableItems: ViewToken[]}) => {
|
||||
for (const viewableItem of viewableItems) {
|
||||
const item = viewableItem.item as Item
|
||||
@@ -276,7 +274,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
)
|
||||
ax.metric('suggestedUser:seen', {
|
||||
logContext: 'ProgressGuide',
|
||||
recId: hasSearchText ? undefined : suggestions?.recId,
|
||||
recId: undefined,
|
||||
position: position !== -1 ? position : 0,
|
||||
suggestedDid: item.profile.did,
|
||||
category: selectedInterestRef.current,
|
||||
@@ -285,13 +283,10 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
const viewabilityConfig = useMemo(
|
||||
() => ({
|
||||
itemVisiblePercentThreshold: 50,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
).current
|
||||
const viewabilityConfig = useRef({
|
||||
itemVisiblePercentThreshold: 50,
|
||||
}).current
|
||||
|
||||
const onSelectTab = useCallback(
|
||||
(interest: string) => {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import {createContext, useCallback, useContext, useId, useMemo} from 'react'
|
||||
import React from 'react'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme, type ViewStyleProp, web} from '#/alf'
|
||||
import {
|
||||
atoms as a,
|
||||
useBreakpoints,
|
||||
useTheme,
|
||||
type ViewStyleProp,
|
||||
web,
|
||||
} from '#/alf'
|
||||
import {Button, type ButtonColor, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -14,7 +20,7 @@ export {
|
||||
useDialogControl as usePromptControl,
|
||||
} from '#/components/Dialog'
|
||||
|
||||
const Context = createContext<{
|
||||
const Context = React.createContext<{
|
||||
titleId: string
|
||||
descriptionId: string
|
||||
}>({
|
||||
@@ -31,15 +37,12 @@ export function Outer({
|
||||
}: React.PropsWithChildren<{
|
||||
control: Dialog.DialogControlProps
|
||||
testID?: string
|
||||
/**
|
||||
* Native-specific options for the prompt. Extends `BottomSheetViewProps`
|
||||
*/
|
||||
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
|
||||
}>) {
|
||||
const titleId = useId()
|
||||
const descriptionId = useId()
|
||||
const titleId = React.useId()
|
||||
const descriptionId = React.useId()
|
||||
|
||||
const context = useMemo(
|
||||
const context = React.useMemo(
|
||||
() => ({titleId, descriptionId}),
|
||||
[titleId, descriptionId],
|
||||
)
|
||||
@@ -55,7 +58,7 @@ export function Outer({
|
||||
<Dialog.ScrollableInner
|
||||
accessibilityLabelledBy={titleId}
|
||||
accessibilityDescribedBy={descriptionId}
|
||||
style={web([{maxWidth: 320, borderRadius: 36}])}>
|
||||
style={web({maxWidth: 400})}>
|
||||
{children}
|
||||
</Dialog.ScrollableInner>
|
||||
</Context.Provider>
|
||||
@@ -67,7 +70,7 @@ export function TitleText({
|
||||
children,
|
||||
style,
|
||||
}: React.PropsWithChildren<ViewStyleProp>) {
|
||||
const {titleId} = useContext(Context)
|
||||
const {titleId} = React.useContext(Context)
|
||||
return (
|
||||
<Text
|
||||
nativeID={titleId}
|
||||
@@ -75,7 +78,7 @@ export function TitleText({
|
||||
a.flex_1,
|
||||
a.text_2xl,
|
||||
a.font_semi_bold,
|
||||
a.pb_xs,
|
||||
a.pb_sm,
|
||||
a.leading_snug,
|
||||
style,
|
||||
]}>
|
||||
@@ -89,7 +92,7 @@ export function DescriptionText({
|
||||
selectable,
|
||||
}: React.PropsWithChildren<{selectable?: boolean}>) {
|
||||
const t = useTheme()
|
||||
const {descriptionId} = useContext(Context)
|
||||
const {descriptionId} = React.useContext(Context)
|
||||
return (
|
||||
<Text
|
||||
nativeID={descriptionId}
|
||||
@@ -100,12 +103,22 @@ export function DescriptionText({
|
||||
)
|
||||
}
|
||||
|
||||
export function Actions({children}: {children: React.ReactNode}) {
|
||||
return <View style={[a.w_full, a.gap_sm, a.justify_end]}>{children}</View>
|
||||
}
|
||||
export function Actions({children}: React.PropsWithChildren<{}>) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
export function Content({children}: {children: React.ReactNode}) {
|
||||
return <View style={[a.pb_sm]}>{children}</View>
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.gap_md,
|
||||
a.justify_end,
|
||||
gtMobile
|
||||
? [a.flex_row, a.flex_row_reverse, a.justify_start]
|
||||
: [a.flex_col],
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Cancel({
|
||||
@@ -117,8 +130,9 @@ export function Cancel({
|
||||
cta?: string
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {close} = Dialog.useDialogContext()
|
||||
const onPress = useCallback(() => {
|
||||
const onPress = React.useCallback(() => {
|
||||
close()
|
||||
}, [close])
|
||||
|
||||
@@ -126,7 +140,7 @@ export function Cancel({
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size={'large'}
|
||||
size={gtMobile ? 'small' : 'large'}
|
||||
label={cta || _(msg`Cancel`)}
|
||||
onPress={onPress}>
|
||||
<ButtonText>{cta || _(msg`Cancel`)}</ButtonText>
|
||||
@@ -156,8 +170,9 @@ export function Action({
|
||||
testID?: string
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {close} = Dialog.useDialogContext()
|
||||
const handleOnPress = useCallback(
|
||||
const handleOnPress = React.useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
close(() => onPress?.(e))
|
||||
},
|
||||
@@ -166,8 +181,9 @@ export function Action({
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color={color}
|
||||
size={'large'}
|
||||
size={gtMobile ? 'small' : 'large'}
|
||||
label={cta || _(msg`Confirm`)}
|
||||
onPress={handleOnPress}
|
||||
testID={testID}>
|
||||
@@ -204,10 +220,8 @@ export function Basic({
|
||||
}>) {
|
||||
return (
|
||||
<Outer control={control} testID="confirmModal">
|
||||
<Content>
|
||||
<TitleText>{title}</TitleText>
|
||||
{description && <DescriptionText>{description}</DescriptionText>}
|
||||
</Content>
|
||||
<TitleText>{title}</TitleText>
|
||||
{description && <DescriptionText>{description}</DescriptionText>}
|
||||
<Actions>
|
||||
<Action
|
||||
cta={confirmButtonCta}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useMemo} from 'react'
|
||||
import React from 'react'
|
||||
import {type StyleProp, type TextStyle} from 'react-native'
|
||||
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
|
||||
|
||||
@@ -27,16 +27,6 @@ export type RichTextProps = TextStyleProp &
|
||||
interactiveStyle?: StyleProp<TextStyle>
|
||||
emojiMultiplier?: number
|
||||
shouldProxyLinks?: boolean
|
||||
/**
|
||||
* DANGEROUS: Disable facet lexicon validation
|
||||
*
|
||||
* `detectFacetsWithoutResolution()` generates technically invalid facets,
|
||||
* with a handle in place of the DID. This means that RichText that uses it
|
||||
* won't be able to render links.
|
||||
*
|
||||
* Use with care - only use if you're rendering facets you're generating yourself.
|
||||
*/
|
||||
disableMentionFacetValidation?: true
|
||||
}
|
||||
|
||||
export function RichText({
|
||||
@@ -54,17 +44,12 @@ export function RichText({
|
||||
onLayout,
|
||||
onTextLayout,
|
||||
shouldProxyLinks,
|
||||
disableMentionFacetValidation,
|
||||
}: RichTextProps) {
|
||||
const richText = useMemo(() => {
|
||||
if (value instanceof RichTextAPI) {
|
||||
return value
|
||||
} else {
|
||||
const rt = new RichTextAPI({text: value})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
return rt
|
||||
}
|
||||
}, [value])
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
|
||||
[value],
|
||||
)
|
||||
|
||||
const plainStyles = [a.leading_snug, style]
|
||||
const interactiveStyles = [plainStyles, interactiveStyle]
|
||||
@@ -113,11 +98,9 @@ export function RichText({
|
||||
const link = segment.link
|
||||
const mention = segment.mention
|
||||
const tag = segment.tag
|
||||
|
||||
if (
|
||||
mention &&
|
||||
(disableMentionFacetValidation ||
|
||||
AppBskyRichtextFacet.validateMention(mention).success) &&
|
||||
AppBskyRichtextFacet.validateMention(mention).success &&
|
||||
!disableLinks
|
||||
) {
|
||||
els.push(
|
||||
|
||||
@@ -101,7 +101,7 @@ export function ProfileStarterPacks({
|
||||
message={
|
||||
emptyStateMessage ??
|
||||
_(
|
||||
msg`Starter packs let you share your favorite feeds and people with your friends.`,
|
||||
'Starter packs let you share your favorite feeds and people with your friends.',
|
||||
)
|
||||
}
|
||||
button={emptyStateButton}
|
||||
@@ -331,17 +331,15 @@ function Empty() {
|
||||
</View>
|
||||
|
||||
<Prompt.Outer control={confirmDialogControl}>
|
||||
<Prompt.Content>
|
||||
<Prompt.TitleText>
|
||||
<Trans>Generate a starter pack</Trans>
|
||||
</Prompt.TitleText>
|
||||
<Prompt.DescriptionText>
|
||||
<Trans>
|
||||
Bluesky will choose a set of recommended accounts from people in
|
||||
your network.
|
||||
</Trans>
|
||||
</Prompt.DescriptionText>
|
||||
</Prompt.Content>
|
||||
<Prompt.TitleText>
|
||||
<Trans>Generate a starter pack</Trans>
|
||||
</Prompt.TitleText>
|
||||
<Prompt.DescriptionText>
|
||||
<Trans>
|
||||
Bluesky will choose a set of recommended accounts from people in
|
||||
your network.
|
||||
</Trans>
|
||||
</Prompt.DescriptionText>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action
|
||||
color="primary"
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
|
||||
import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_E2E, IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {createIsEnabledCheck, isExistingUserAsOf} from './utils'
|
||||
|
||||
export const enabled = createIsEnabledCheck(props => {
|
||||
return (
|
||||
!IS_E2E &&
|
||||
IS_NATIVE &&
|
||||
isExistingUserAsOf(
|
||||
'2026-02-05T00:00:00.000Z',
|
||||
props.currentProfile.createdAt,
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
export function DraftsAnnouncement() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const nuxDialogs = useNuxDialogContext()
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
Dialog.useAutoOpen(control)
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
nuxDialogs.dismissActiveNux()
|
||||
}, [nuxDialogs])
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={onClose}
|
||||
nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle fill={t.palette.primary_400} />
|
||||
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Introducing drafts`)}
|
||||
style={[web({maxWidth: 440})]}
|
||||
contentContainerStyle={[
|
||||
{
|
||||
paddingTop: 0,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.overflow_hidden,
|
||||
{
|
||||
paddingTop: IS_WEB ? 24 : 40,
|
||||
borderTopLeftRadius: a.rounded_md.borderRadius,
|
||||
borderTopRightRadius: a.rounded_md.borderRadius,
|
||||
},
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={[t.palette.primary_100, t.palette.primary_200]}
|
||||
locations={[0, 1]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 0, y: 1}}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
<View
|
||||
style={[a.flex_row, a.align_center, a.gap_xs, {marginBottom: -12}]}>
|
||||
<SparkleIcon fill={t.palette.primary_800} size="sm" />
|
||||
<Text
|
||||
style={[
|
||||
a.font_semi_bold,
|
||||
{
|
||||
color: t.palette.primary_800,
|
||||
},
|
||||
]}>
|
||||
<Trans>New Feature</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={require('../../../../assets/images/drafts_announcement_nux.webp')}
|
||||
style={[
|
||||
a.w_full,
|
||||
{
|
||||
aspectRatio: 393 / 226,
|
||||
},
|
||||
]}
|
||||
alt={_(
|
||||
msg({
|
||||
message: `A screenshot of the post composer with a new button next to the post button that says "Drafts", with a rainbow firework effect. Below, the text in the composer reads "Hey, did you hear the news? Bluesky has drafts now!!!".`,
|
||||
comment:
|
||||
'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.',
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.align_center, a.px_xl, a.pt_xl, a.gap_2xl, a.pb_sm]}>
|
||||
<View style={[a.gap_sm, a.align_center]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_3xl,
|
||||
a.leading_tight,
|
||||
a.font_bold,
|
||||
a.text_center,
|
||||
{
|
||||
fontSize: IS_WEB ? 28 : 32,
|
||||
maxWidth: 300,
|
||||
},
|
||||
]}>
|
||||
<Trans>Drafts</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
a.text_center,
|
||||
{
|
||||
maxWidth: 340,
|
||||
},
|
||||
]}>
|
||||
<Trans>
|
||||
Not ready to hit post? Keep your best ideas in Drafts until the
|
||||
timing is just right.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{!IS_WEB && (
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="large"
|
||||
color="primary"
|
||||
onPress={() => control.close()}
|
||||
style={[a.w_full]}>
|
||||
<ButtonText>
|
||||
<Trans>Finally!</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -19,9 +19,9 @@ import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {type SessionAccount, useSession} from '#/state/session'
|
||||
import {useOnboardingState} from '#/state/shell'
|
||||
import {
|
||||
DraftsAnnouncement,
|
||||
enabled as isDraftsAnnouncementEnabled,
|
||||
} from '#/components/dialogs/nuxs/DraftsAnnouncement'
|
||||
enabled as isLiveNowBetaDialogEnabled,
|
||||
LiveNowBetaDialog,
|
||||
} from '#/components/dialogs/nuxs/LiveNowBetaDialog'
|
||||
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
|
||||
import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
@@ -37,8 +37,8 @@ const queuedNuxs: {
|
||||
enabled?: (props: EnabledCheckProps) => boolean
|
||||
}[] = [
|
||||
{
|
||||
id: Nux.DraftsAnnouncement,
|
||||
enabled: isDraftsAnnouncementEnabled,
|
||||
id: Nux.LiveNowBetaDialog,
|
||||
enabled: isLiveNowBetaDialogEnabled,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -186,7 +186,7 @@ function Inner({
|
||||
return (
|
||||
<Context.Provider value={ctx}>
|
||||
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
|
||||
{activeNux === Nux.DraftsAnnouncement && <DraftsAnnouncement />}
|
||||
{activeNux === Nux.LiveNowBetaDialog && <LiveNowBetaDialog />}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,13 +40,11 @@ export function LeaveConvoPrompt({
|
||||
<Prompt.Basic
|
||||
control={control}
|
||||
title={_(msg`Leave conversation`)}
|
||||
description={
|
||||
description={_(
|
||||
hasMessages
|
||||
? _(
|
||||
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`,
|
||||
)
|
||||
: _(msg`Are you sure you want to leave this conversation?`)
|
||||
}
|
||||
? msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`
|
||||
: msg`Are you sure you want to leave this conversation?`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Leave`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={() => leaveConvo()}
|
||||
|
||||
@@ -8,23 +8,27 @@ export function useWelcomeModal() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const open = () => setIsOpen(true)
|
||||
const close = () => setIsOpen(false)
|
||||
const close = () => {
|
||||
setIsOpen(false)
|
||||
// Mark that user has actively closed the modal, don't show again this session
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('welcomeModalClosed', 'true')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Only show modal if:
|
||||
// 1. User is not logged in
|
||||
// 2. We're on the web (this is a web-only feature)
|
||||
// 3. We're on the homepage (path is '/' or '/home')
|
||||
// 4. Modal hasn't been shown before
|
||||
// 4. User hasn't actively closed the modal in this session
|
||||
if (IS_WEB && !hasSession && typeof window !== 'undefined') {
|
||||
const currentPath = window.location.pathname
|
||||
const isHomePage = currentPath === '/'
|
||||
const hasModalBeenShown =
|
||||
localStorage.getItem('welcomeModalShown') === 'true'
|
||||
const hasUserClosedModal =
|
||||
sessionStorage.getItem('welcomeModalClosed') === 'true'
|
||||
|
||||
if (isHomePage && !hasModalBeenShown) {
|
||||
// Mark that the modal has been shown, don't show again
|
||||
localStorage.setItem('welcomeModalShown', 'true')
|
||||
if (isHomePage && !hasUserClosedModal) {
|
||||
// Small delay to ensure the page has loaded
|
||||
const timer = setTimeout(() => {
|
||||
open()
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const PageX_Stroke2_Corner0_Rounded_Large = createSinglePathSVG({
|
||||
viewBox: '0 0 64 64',
|
||||
path: 'M32.457 7c1.68 0 3.29.668 4.478 1.855L49.813 21.73a6.33 6.33 0 0 1 1.854 4.479v24.458A6.333 6.333 0 0 1 45.333 57H18.666a6.334 6.334 0 0 1-6.333-6.333V13.333A6.334 6.334 0 0 1 18.666 7h13.791ZM18.666 9a4.334 4.334 0 0 0-4.333 4.333v37.334A4.334 4.334 0 0 0 18.666 55h26.667a4.333 4.333 0 0 0 4.333-4.333V26.209c0-.418-.061-.829-.177-1.223a1 1 0 0 1-.155.014H40a6.334 6.334 0 0 1-6.325-6.008l-.008-.326V9.333q0-.08.013-.156A4.3 4.3 0 0 0 32.457 9H18.666Zm18.627 22.293a1 1 0 1 1 1.414 1.414L33.414 38l5.293 5.293a1 1 0 1 1-1.414 1.414L32 39.414l-5.293 5.293a1 1 0 1 1-1.414-1.414L30.586 38l-5.293-5.293a1 1 0 1 1 1.414-1.414L32 36.586l5.293-5.293Zm-1.626-12.627.006.224A4.333 4.333 0 0 0 40 23h8.253L35.667 10.414v8.252Z',
|
||||
})
|
||||
@@ -55,7 +55,7 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
|
||||
const tick = useTickEveryMinute()
|
||||
const liveNowConfig = useLiveNowConfig()
|
||||
const {formatted: allowedServices} = getLiveServiceNames(
|
||||
liveNowConfig.currentAccountAllowedHosts,
|
||||
liveNowConfig.allowedDomains,
|
||||
)
|
||||
|
||||
const time = useCallback(
|
||||
|
||||
@@ -31,10 +31,8 @@ export function useLiveLinkMetaQuery(url: string | null) {
|
||||
queryFn: async () => {
|
||||
if (!url) return undefined
|
||||
const urlp = new URL(url)
|
||||
if (!liveNowConfig.currentAccountAllowedHosts.has(urlp.hostname)) {
|
||||
const {formatted} = getLiveServiceNames(
|
||||
liveNowConfig.currentAccountAllowedHosts,
|
||||
)
|
||||
if (!liveNowConfig.allowedDomains.has(urlp.hostname)) {
|
||||
const {formatted} = getLiveServiceNames(liveNowConfig.allowedDomains)
|
||||
throw new Error(
|
||||
_(
|
||||
msg`This service is not supported while the Live feature is in beta. Allowed services: ${formatted}.`,
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import {useMemo, useState} from 'react'
|
||||
import {
|
||||
LayoutAnimation,
|
||||
type StyleProp,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import React from 'react'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {type ModerationUI} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
ADULT_CONTENT_LABELS,
|
||||
type AdultSelfLabel,
|
||||
isJustAMute,
|
||||
} from '#/lib/moderation'
|
||||
import {ADULT_CONTENT_LABELS, isJustAMute} from '#/lib/moderation'
|
||||
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
|
||||
import {getDefinition, getLabelStrings} from '#/lib/moderation/useLabelInfo'
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
@@ -69,17 +60,16 @@ function ContentHiderActive({
|
||||
style,
|
||||
childContainerStyle,
|
||||
children,
|
||||
}: {
|
||||
}: React.PropsWithChildren<{
|
||||
testID?: string
|
||||
modui: ModerationUI
|
||||
style?: StyleProp<ViewStyle>
|
||||
childContainerStyle?: StyleProp<ViewStyle>
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
}>) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [override, setOverride] = useState(false)
|
||||
const [override, setOverride] = React.useState(false)
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const {labelDefs} = useLabelDefinitions()
|
||||
const globalLabelStrings = useGlobalLabelStrings()
|
||||
@@ -87,7 +77,7 @@ function ContentHiderActive({
|
||||
const blur = modui?.blurs[0]
|
||||
const desc = useModerationCauseDescription(blur)
|
||||
|
||||
const labelName = useMemo(() => {
|
||||
const labelName = React.useMemo(() => {
|
||||
if (!modui?.blurs || !blur) {
|
||||
return undefined
|
||||
}
|
||||
@@ -111,7 +101,7 @@ function ContentHiderActive({
|
||||
if (cause.source.type !== 'user') {
|
||||
return false
|
||||
}
|
||||
if (ADULT_CONTENT_LABELS.includes(cause.label.val as AdultSelfLabel)) {
|
||||
if (ADULT_CONTENT_LABELS.includes(cause.label.val)) {
|
||||
if (hasAdultContentLabel) {
|
||||
return false
|
||||
}
|
||||
@@ -156,7 +146,6 @@ function ContentHiderActive({
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!modui.noOverride) {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setOverride(v => !v)
|
||||
} else {
|
||||
control.open()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {useCallback, useState} from 'react'
|
||||
import React, {type ComponentProps} from 'react'
|
||||
import {
|
||||
LayoutAnimation,
|
||||
Pressable,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
@@ -18,7 +17,7 @@ import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {addStyle} from '#/lib/styles'
|
||||
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||
import {precacheProfile} from '#/state/queries/profile'
|
||||
// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up
|
||||
import {Link} from '#/view/com/util/Link'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
@@ -28,7 +27,7 @@ import {
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Link> {
|
||||
interface Props extends ComponentProps<typeof Link> {
|
||||
disabled: boolean
|
||||
iconSize: number
|
||||
iconStyles: StyleProp<ViewStyle>
|
||||
@@ -55,15 +54,15 @@ export function PostHider({
|
||||
const queryClient = useQueryClient()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const [override, setOverride] = useState(false)
|
||||
const [override, setOverride] = React.useState(false)
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const blur =
|
||||
modui.blurs[0] ||
|
||||
(interpretFilterAsBlur ? getBlurrableFilter(modui) : undefined)
|
||||
const desc = useModerationCauseDescription(blur)
|
||||
|
||||
const onBeforePress = useCallback(() => {
|
||||
unstableCacheProfileView(queryClient, profile)
|
||||
const onBeforePress = React.useCallback(() => {
|
||||
precacheProfile(queryClient, profile)
|
||||
}, [queryClient, profile])
|
||||
|
||||
if (!blur || (disabled && !modui.noOverride)) {
|
||||
@@ -84,15 +83,14 @@ export function PostHider({
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
if (!modui.noOverride) {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setOverride(v => !v)
|
||||
}
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
accessibilityHint={
|
||||
override ? _(msg`Hides the content`) : _(msg`Shows the content`)
|
||||
}
|
||||
accessibilityHint=""
|
||||
accessibilityLabel=""
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
|
||||
@@ -28,7 +28,7 @@ export function VerifierDialog({
|
||||
verificationState: FullVerificationState
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<Inner
|
||||
control={control}
|
||||
@@ -123,6 +123,7 @@ function Inner({
|
||||
}),
|
||||
)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
style={[a.justify_center]}
|
||||
onPress={() => {
|
||||
@@ -137,6 +138,7 @@ function Inner({
|
||||
<Button
|
||||
label={_(msg`Close dialog`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onPress={() => {
|
||||
control.close()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {createContext, useContext, useMemo} from 'react'
|
||||
import {hasMutedWord} from '@atproto/api'
|
||||
import {QueryClient, useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {useOnAppStateChange} from '#/lib/appState'
|
||||
@@ -9,8 +8,7 @@ import {
|
||||
isBskyCustomFeedUrl,
|
||||
makeRecordUri,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {IS_DEV, LIVE_EVENTS_URL} from '#/env'
|
||||
import {LIVE_EVENTS_URL} from '#/env'
|
||||
import {useLiveEventPreferences} from '#/features/liveEvents/preferences'
|
||||
import {type LiveEventsWorkerResponse} from '#/features/liveEvents/types'
|
||||
import {useDevMode} from '#/storage/hooks/dev-mode'
|
||||
@@ -38,12 +36,6 @@ const Context = createContext<LiveEventsWorkerResponse>(DEFAULT_LIVE_EVENTS)
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const [isDevMode] = useDevMode()
|
||||
const isBskyTeam = useIsBskyTeam()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const mutedWords = useMemo(
|
||||
() => preferences?.moderationPrefs?.mutedWords ?? [],
|
||||
[preferences?.moderationPrefs?.mutedWords],
|
||||
)
|
||||
|
||||
const {data, refetch} = useQuery(
|
||||
{
|
||||
// keep this, prefectching handles initial load
|
||||
@@ -58,24 +50,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
useOnAppStateChange(state => {
|
||||
if (state === 'active') void refetch()
|
||||
if (state === 'active') refetch()
|
||||
})
|
||||
|
||||
const ctx = useMemo(() => {
|
||||
if (!data) return DEFAULT_LIVE_EVENTS
|
||||
const skipMuteFilter = isBskyTeam || IS_DEV
|
||||
const feeds = data.feeds.filter(f => {
|
||||
if (f.preview && !isBskyTeam) return false
|
||||
if (!skipMuteFilter && mutedWords.length > 0) {
|
||||
const text = [
|
||||
f.title,
|
||||
f.layouts?.wide?.title,
|
||||
f.layouts?.compact?.title,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
if (hasMutedWord({mutedWords, text})) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return {
|
||||
@@ -83,7 +64,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
// only one at a time for now, unless bsky team and dev mode
|
||||
feeds: isBskyTeam && isDevMode ? feeds : feeds.slice(0, 1),
|
||||
}
|
||||
}, [data, isBskyTeam, isDevMode, mutedWords])
|
||||
}, [data, isBskyTeam, isDevMode])
|
||||
|
||||
return <Context.Provider value={ctx}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
|
||||
|
||||
jest.mock('@bsky.app/react-native-mmkv', () => ({
|
||||
MMKV: class MMKVMock {
|
||||
_store = new Map<string, string>()
|
||||
|
||||
getString(key: string) {
|
||||
return this._store.get(key)
|
||||
}
|
||||
|
||||
set(key: string, value: string) {
|
||||
this._store.set(key, value)
|
||||
}
|
||||
|
||||
delete(key: string) {
|
||||
this._store.delete(key)
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
this._store.clear()
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
import {createPersistedQueryStorage} from '../persisted-query-storage'
|
||||
|
||||
describe('createPersistedQueryStorage', () => {
|
||||
it('should create isolated storage instances', async () => {
|
||||
const storage1 = createPersistedQueryStorage('store1')
|
||||
const storage2 = createPersistedQueryStorage('store2')
|
||||
|
||||
await storage1.setItem('key', 'value1')
|
||||
await storage2.setItem('key', 'value2')
|
||||
|
||||
expect(await storage1.getItem('key')).toBe('value1')
|
||||
expect(await storage2.getItem('key')).toBe('value2')
|
||||
})
|
||||
|
||||
describe('storage operations', () => {
|
||||
let storage: ReturnType<typeof createPersistedQueryStorage>
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createPersistedQueryStorage('test_store')
|
||||
})
|
||||
|
||||
it('should return null for non-existent keys', async () => {
|
||||
const result = await storage.getItem('non-existent-key')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should store and retrieve a value', async () => {
|
||||
const testValue = JSON.stringify({data: 'test'})
|
||||
await storage.setItem('test-key', testValue)
|
||||
const result = await storage.getItem('test-key')
|
||||
expect(result).toBe(testValue)
|
||||
})
|
||||
|
||||
it('should remove a value', async () => {
|
||||
const testValue = JSON.stringify({data: 'test'})
|
||||
await storage.setItem('test-key', testValue)
|
||||
await storage.removeItem('test-key')
|
||||
const result = await storage.getItem('test-key')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle complex JSON data', async () => {
|
||||
const complexData = JSON.stringify({
|
||||
queries: [
|
||||
{key: 'query1', data: {nested: {value: 123}}},
|
||||
{key: 'query2', data: {array: [1, 2, 3]}},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
await storage.setItem('complex-key', complexData)
|
||||
const result = await storage.getItem('complex-key')
|
||||
expect(result).toBe(complexData)
|
||||
expect(JSON.parse(result!)).toEqual(JSON.parse(complexData))
|
||||
})
|
||||
|
||||
it('should overwrite existing values', async () => {
|
||||
await storage.setItem('test-key', 'value1')
|
||||
await storage.setItem('test-key', 'value2')
|
||||
const result = await storage.getItem('test-key')
|
||||
expect(result).toBe('value2')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type $Typed,
|
||||
type AppBskyActorDefs,
|
||||
AppBskyEmbedExternal,
|
||||
AtUri,
|
||||
} from '@atproto/api'
|
||||
import {isAfter, parseISO} from 'date-fns'
|
||||
|
||||
@@ -21,7 +20,7 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
|
||||
void tick // revalidate every minute
|
||||
|
||||
if (shadowed && 'status' in shadowed && shadowed.status) {
|
||||
const isValid = isStatusValidForViewers(shadowed.status, config)
|
||||
const isValid = validateStatus(shadowed.status, config)
|
||||
const isDisabled = shadowed.status.isDisabled || false
|
||||
const isActive = isStatusStillActive(shadowed.status.expiresAt)
|
||||
if (isValid && !isDisabled && isActive) {
|
||||
@@ -65,24 +64,15 @@ export function isStatusStillActive(timeStr: string | undefined) {
|
||||
return isAfter(expiry, now)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates whether the live status is valid for display in the app. Does NOT
|
||||
* validate if the status is valid for the acting user e.g. as they go live.
|
||||
*/
|
||||
export function isStatusValidForViewers(
|
||||
export function validateStatus(
|
||||
status: AppBskyActorDefs.StatusView,
|
||||
config: LiveNowConfig,
|
||||
) {
|
||||
if (status.status !== 'app.bsky.actor.status#live') return false
|
||||
if (!status.uri) return false // should not happen, just backwards compat
|
||||
try {
|
||||
const {host: liveDid} = new AtUri(status.uri)
|
||||
if (AppBskyEmbedExternal.isView(status.embed)) {
|
||||
const url = new URL(status.embed.external.uri)
|
||||
const exception = config.allowedHostsExceptionsByDid.get(liveDid)
|
||||
const isValidException = exception ? exception.has(url.hostname) : false
|
||||
const isValidForAnyone = config.defaultAllowedHosts.has(url.hostname)
|
||||
return isValidException || isValidForAnyone
|
||||
return config.allowedDomains.has(url.hostname)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -354,8 +354,6 @@ async function resolveMedia(
|
||||
alt: videoDraft.altText || undefined,
|
||||
captions: captions.length === 0 ? undefined : captions,
|
||||
aspectRatio,
|
||||
presentation:
|
||||
videoDraft.video.mimeType === 'image/gif' ? 'gif' : 'default',
|
||||
}
|
||||
}
|
||||
if (embedDraft.media?.type === 'gif') {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import * as Device from 'expo-device'
|
||||
|
||||
import * as env from '#/env'
|
||||
|
||||
export const FALLBACK_ANDROID = 'Android'
|
||||
export const FALLBACK_IOS = 'iOS'
|
||||
export const FALLBACK_WEB = 'Web'
|
||||
|
||||
export function getDeviceName(): string {
|
||||
const deviceName = Device.deviceName
|
||||
if (env.IS_ANDROID) {
|
||||
return deviceName || FALLBACK_ANDROID
|
||||
} else if (env.IS_IOS) {
|
||||
return deviceName || FALLBACK_IOS
|
||||
} else {
|
||||
return FALLBACK_WEB // could append browser info here
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import {useCallback, useRef} from 'react'
|
||||
import React from 'react'
|
||||
|
||||
export function useDedupe(timeout = 250) {
|
||||
const canDo = useRef(true)
|
||||
export const useDedupe = (timeout = 250) => {
|
||||
const canDo = React.useRef(true)
|
||||
|
||||
return useCallback(
|
||||
return React.useCallback(
|
||||
(cb: () => unknown) => {
|
||||
if (canDo.current) {
|
||||
canDo.current = false
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import {
|
||||
KeyboardProvider,
|
||||
useKeyboardController,
|
||||
} from 'react-native-keyboard-controller'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
const KeyboardControllerRefCountContext = createContext<{
|
||||
incrementRefCount: () => void
|
||||
decrementRefCount: () => void
|
||||
}>({
|
||||
incrementRefCount: () => {},
|
||||
decrementRefCount: () => {},
|
||||
})
|
||||
KeyboardControllerRefCountContext.displayName =
|
||||
'KeyboardControllerRefCountContext'
|
||||
|
||||
export function KeyboardControllerProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<KeyboardProvider enabled={false}>
|
||||
<KeyboardControllerProviderInner>
|
||||
{children}
|
||||
</KeyboardControllerProviderInner>
|
||||
</KeyboardProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyboardControllerProviderInner({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const {setEnabled} = useKeyboardController()
|
||||
const refCount = useRef(0)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
incrementRefCount: () => {
|
||||
refCount.current++
|
||||
setEnabled(refCount.current > 0)
|
||||
},
|
||||
decrementRefCount: () => {
|
||||
refCount.current--
|
||||
setEnabled(refCount.current > 0)
|
||||
|
||||
if (__DEV__ && refCount.current < 0) {
|
||||
console.error('KeyboardController ref count < 0')
|
||||
}
|
||||
},
|
||||
}),
|
||||
[setEnabled],
|
||||
)
|
||||
|
||||
return (
|
||||
<KeyboardControllerRefCountContext.Provider value={value}>
|
||||
{children}
|
||||
</KeyboardControllerRefCountContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useEnableKeyboardController(shouldEnable: boolean) {
|
||||
const {incrementRefCount, decrementRefCount} = useContext(
|
||||
KeyboardControllerRefCountContext,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldEnable) {
|
||||
return
|
||||
}
|
||||
incrementRefCount()
|
||||
return () => {
|
||||
decrementRefCount()
|
||||
}
|
||||
}, [shouldEnable, incrementRefCount, decrementRefCount])
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `useEnableKeyboardController`, but using `useFocusEffect`
|
||||
*/
|
||||
export function useEnableKeyboardControllerScreen(shouldEnable: boolean) {
|
||||
const {incrementRefCount, decrementRefCount} = useContext(
|
||||
KeyboardControllerRefCountContext,
|
||||
)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!shouldEnable) {
|
||||
return
|
||||
}
|
||||
incrementRefCount()
|
||||
return () => {
|
||||
decrementRefCount()
|
||||
}
|
||||
}, [shouldEnable, incrementRefCount, decrementRefCount]),
|
||||
)
|
||||
}
|
||||
@@ -128,7 +128,6 @@ export function useComposeIntent() {
|
||||
openComposer({
|
||||
text: text ?? undefined,
|
||||
videoUri: {uri, width: Number(width), height: Number(height)},
|
||||
logContext: 'Deeplink',
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -154,7 +153,6 @@ export function useComposeIntent() {
|
||||
openComposer({
|
||||
text: text ?? undefined,
|
||||
imageUris: IS_NATIVE ? imageUris : undefined,
|
||||
logContext: 'Deeplink',
|
||||
})
|
||||
}, 500)
|
||||
},
|
||||
|
||||
@@ -14,12 +14,9 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {type AppModerationCause} from '#/components/Pills'
|
||||
|
||||
export const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn'] as const
|
||||
export const OTHER_SELF_LABELS = ['graphic-media'] as const
|
||||
export const SELF_LABELS = [
|
||||
...ADULT_CONTENT_LABELS,
|
||||
...OTHER_SELF_LABELS,
|
||||
] as const
|
||||
export const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn']
|
||||
export const OTHER_SELF_LABELS = ['graphic-media']
|
||||
export const SELF_LABELS = [...ADULT_CONTENT_LABELS, ...OTHER_SELF_LABELS]
|
||||
|
||||
export type AdultSelfLabel = (typeof ADULT_CONTENT_LABELS)[number]
|
||||
export type OtherSelfLabel = (typeof OTHER_SELF_LABELS)[number]
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import {create as createArchiveDB} from '#/storage/archive/db'
|
||||
|
||||
/**
|
||||
* Interface for async storage compatible with @tanstack/query-async-storage-persister
|
||||
*/
|
||||
export interface PersistedQueryStorage {
|
||||
getItem: (key: string) => Promise<string | null>
|
||||
setItem: (key: string, value: string) => Promise<void>
|
||||
removeItem: (key: string) => Promise<void>
|
||||
}
|
||||
|
||||
function createId(id: string) {
|
||||
return `react-query-cache-${id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an MMKV-based storage adapter for persisting react-query cache on native platforms.
|
||||
* Each storage instance uses a separate MMKV store identified by the provided id.
|
||||
* MMKV provides synchronous access but we wrap it in Promises for API compatibility.
|
||||
*
|
||||
* @param id - Unique identifier for this storage instance (used as MMKV store id)
|
||||
*/
|
||||
export function createPersistedQueryStorage(id: string): PersistedQueryStorage {
|
||||
const store = createArchiveDB({id: createId(id)})
|
||||
return {
|
||||
getItem: async (key: string): Promise<string | null> => {
|
||||
return (await store.get(key)) ?? null
|
||||
},
|
||||
setItem: async (key: string, value: string): Promise<void> => {
|
||||
await store.set(key, value)
|
||||
},
|
||||
removeItem: async (key: string): Promise<void> => {
|
||||
await store.delete(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearPersistedQueryStorage(id: string) {
|
||||
const store = createArchiveDB({id: createId(id)})
|
||||
await store.clear()
|
||||
}
|
||||
@@ -1,26 +1,27 @@
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {AppState, type AppStateStatus} from 'react-native'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||
import {focusManager, onlineManager, QueryClient} from '@tanstack/react-query'
|
||||
import {
|
||||
type PersistQueryClientOptions,
|
||||
PersistQueryClientProvider,
|
||||
type PersistQueryClientProviderProps,
|
||||
} from '@tanstack/react-query-persist-client'
|
||||
import type React from 'react'
|
||||
|
||||
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
|
||||
import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events'
|
||||
import {PERSISTED_QUERY_ROOT} from '#/state/queries'
|
||||
import * as env from '#/env'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
__TANSTACK_QUERY_CLIENT__: import('@tanstack/query-core').QueryClient
|
||||
}
|
||||
}
|
||||
|
||||
// any query keys in this array will be persisted to AsyncStorage
|
||||
export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info'
|
||||
const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot]
|
||||
|
||||
async function checkIsOnline(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
@@ -137,8 +138,7 @@ const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehyd
|
||||
{
|
||||
shouldDehydrateMutation: (_: any) => false,
|
||||
shouldDehydrateQuery: query => {
|
||||
const root = String(query.queryKey[0])
|
||||
return root === PERSISTED_QUERY_ROOT
|
||||
return STORED_CACHE_QUERY_KEY_ROOTS.includes(String(query.queryKey[0]))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -177,16 +177,14 @@ function QueryProviderInner({
|
||||
// Do not move the query client creation outside of this component.
|
||||
const [queryClient, _setQueryClient] = useState(() => createQueryClient())
|
||||
const [persistOptions, _setPersistOptions] = useState(() => {
|
||||
const storage = createPersistedQueryStorage(currentDid ?? 'logged-out')
|
||||
const asyncPersister = createAsyncStoragePersister({
|
||||
storage,
|
||||
storage: AsyncStorage,
|
||||
key: 'queryClient-' + (currentDid ?? 'logged-out'),
|
||||
})
|
||||
return {
|
||||
persister: asyncPersister,
|
||||
dehydrateOptions,
|
||||
buster: env.APP_VERSION,
|
||||
} satisfies Omit<PersistQueryClientOptions, 'queryClient'>
|
||||
}
|
||||
})
|
||||
useEffect(() => {
|
||||
if (IS_WEB) {
|
||||
|
||||
@@ -558,16 +558,6 @@ export function parseTenorGif(urlp: URL):
|
||||
width: Number(w),
|
||||
}
|
||||
|
||||
// Validate dimensions are valid positive numbers
|
||||
if (
|
||||
isNaN(dimensions.height) ||
|
||||
isNaN(dimensions.width) ||
|
||||
dimensions.height <= 0 ||
|
||||
dimensions.width <= 0
|
||||
) {
|
||||
return {success: false}
|
||||
}
|
||||
|
||||
if (IS_WEB) {
|
||||
if (IS_WEB_SAFARI) {
|
||||
id = id.replace('AAAAC', 'AAAP1')
|
||||
|
||||