Compare commits

..

3 Commits

Author SHA1 Message Date
Eric Bailey 1e75948f91 Remove test 2026-01-26 13:28:57 -06:00
Eric Bailey 6a442675d9 Cleanup 2026-01-26 13:28:02 -06:00
Eric Bailey 2886816c1e Add idb-keyval backed archival storage 2026-01-25 18:08:12 -06:00
209 changed files with 24980 additions and 34776 deletions
+6 -6
View File
@@ -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
+10 -56
View File
@@ -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'
+26 -41
View File
@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

-2
View File
@@ -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
-188
View File
@@ -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)
})
-1
View File
@@ -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'),
},
}
-110
View File
@@ -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,
})
},
}
},
}
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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",
+1 -1
View File
@@ -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'
+3 -4
View File
@@ -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(
+2 -2
View File
@@ -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({
+1 -3
View File
@@ -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',
}),
/**
+5 -52
View File
@@ -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
}
+7 -7
View File
@@ -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
}
+4 -4
View File
@@ -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 (
+125 -102
View File
@@ -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
+1 -1
View File
@@ -157,7 +157,7 @@ export function Link({
to={{
screen: 'Profile',
params: {
name: labeler.creator.did,
name: labeler.creator.handle,
},
}}
label={_(
+19 -26
View File
@@ -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,
},
+143 -19
View File
@@ -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,
+24 -32
View File
@@ -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
-1
View File
@@ -185,7 +185,6 @@ let PostControls = ({
openComposer({
quote: post,
onPost: onPostReply,
logContext: 'QuotePost',
})
}
+15 -20
View File
@@ -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) => {
+40 -26
View File
@@ -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}
+7 -24
View File
@@ -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>
)
}
+6 -6
View File
@@ -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>
)
}
+4 -6
View File
@@ -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()}
+11 -7
View File
@@ -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()
-6
View File
@@ -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',
})
+1 -1
View File
@@ -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(
+2 -4
View File
@@ -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}.`,
+8 -19
View File
@@ -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()
+8 -10
View File
@@ -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()
+3 -22
View File
@@ -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 -13
View File
@@ -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
}
-2
View File
@@ -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') {
-18
View File
@@ -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
}
}
+4 -4
View File
@@ -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]),
)
}
-2
View File
@@ -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)
},
+3 -6
View File
@@ -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]
-41
View File
@@ -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()
}
+9 -11
View File
@@ -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) {
-10
View File
@@ -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')
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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