diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 247217fe2d..cca7b91c52 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -51,4 +51,4 @@ jobs: # NOTE(sfn): we can add a custom system prompt here claude_args: | - --model claude-opus-4-5-20251101 + --model claude-opus-4-7 diff --git a/assets/icons/arrowBoxRight_stroke2_corner3_rounded.svg b/assets/icons/arrowBoxRight_stroke2_corner3_rounded.svg new file mode 100644 index 0000000000..7006406561 --- /dev/null +++ b/assets/icons/arrowBoxRight_stroke2_corner3_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/chainLinkBroken_stroke2_corner0_rounded.svg b/assets/icons/chainLinkBroken_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..c5197634c7 --- /dev/null +++ b/assets/icons/chainLinkBroken_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/unlock_stroke2_corner2_rounded.svg b/assets/icons/unlock_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..a9fefda12e --- /dev/null +++ b/assets/icons/unlock_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/jest/jestSetup.js b/jest/jestSetup.js index 6a6987c79d..73a509d036 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -61,9 +61,13 @@ jest.mock('expo-media-library', () => ({ usePermissions: jest.fn(() => [true]), })) -jest.mock('lande', () => ({ - __esModule: true, // this property makes it work - default: jest.fn().mockReturnValue([['eng']]), +jest.mock('@bsky.app/expo-guess-language', () => ({ + guessLanguageSync: jest + .fn() + .mockReturnValue([{language: 'en', confidence: 1}]), + guessLanguageAsync: jest + .fn() + .mockResolvedValue([{language: 'en', confidence: 1}]), })) jest.mock('sentry-expo', () => ({ diff --git a/modules/BlueskyClip/README.md b/modules/BlueskyClip/README.md new file mode 100644 index 0000000000..147ee50e6d --- /dev/null +++ b/modules/BlueskyClip/README.md @@ -0,0 +1,134 @@ +# BlueskyClip + +An iOS App Clip implementation for Bluesky starter packs. App Clips are lightweight app experiences that allow users to preview and join Bluesky through starter packs without installing the full app. + +## What It Does + +BlueskyClip provides a minimal, on-demand iOS app experience for viewing and joining Bluesky starter packs. When a user encounters a starter pack link (e.g., `bsky.app/start/...` or `go.bsky.app/...`), iOS can present the App Clip instead of requiring a full app install. The App Clip: + +1. Loads the starter pack web page in a WKWebView +2. Allows users to browse the starter pack content +3. Presents the App Store overlay when the user decides to join +4. Passes the starter pack URI to the main app via shared UserDefaults + +## Architecture + +### Native iOS Implementation + +The App Clip is a standalone iOS target with its own minimal Swift implementation: + +- **AppDelegate.swift**: Standard app delegate that sets up the view controller and handles URL routing (both direct URL opens and universal links) +- **ViewController.swift**: Main view controller that manages the WKWebView, detects starter pack URLs, and communicates with the web layer + +### Communication Flow + +``` +User taps starter pack link + ↓ +iOS presents BlueskyClip App Clip + ↓ +WKWebView loads bsky.app with ?clip=true parameter + ↓ +Web app detects clip mode and sends actions via postMessage + ↓ +ViewController receives messages and: + - Presents App Store overlay (action: "present") + - Stores starter pack URI in shared UserDefaults (action: "store") + ↓ +User downloads main app + ↓ +Main app reads starterPackUri from shared UserDefaults + ↓ +Main app displays starter pack onboarding flow +``` + +### Key Implementation Details + +**URL Detection** (`isStarterPackUrl`): +- Matches `bsky.app/start/*` and `bsky.app/starter-pack/*` paths (4 path components) +- Matches short links `go.bsky.app/*` (2 path components) + +**WebView Communication** (`WKScriptMessageHandler`): +- Listens for messages on the "onMessage" channel +- Handles two action types: + - `present`: Shows the App Store overlay using `SKOverlay` + - `store`: Writes JSON data to shared UserDefaults with the specified key + +**Data Sharing**: +- Uses UserDefaults suite `group.app.bsky` (App Group) +- Primary key: `starterPackUri` - stores the starter pack URL +- The main app reads this value on launch via `SharedPrefs.getString('starterPackUri')` (see `src/components/hooks/useStarterPackEntry.native.ts`) + +## Configuration + +### Build Configuration + +The App Clip target is automatically configured via Expo config plugins located in `/plugins/starterPackAppClipExtension/`: + +- **withStarterPackAppClip.js**: Main plugin that orchestrates all configuration +- **withXcodeTarget.js**: Creates the App Clip target in Xcode with proper build settings +- **withAppEntitlements.js**: Configures main app entitlements for App Clip association +- **withClipEntitlements.js**: Sets up App Clip entitlements (App Groups, parent app identifier, associated domains) +- **withClipInfoPlist.js**: Generates the Info.plist for the App Clip target +- **withFiles.js**: Copies Swift source files and assets from `modules/BlueskyClip/` to the iOS build directory + +### Entitlements + +**Main App** (`app.entitlements`): +- `com.apple.security.application-groups`: `group.app.bsky` +- `com.apple.developer.associated-appclip-app-identifiers`: Links to the App Clip bundle ID + +**App Clip** (`BlueskyClip.entitlements`): +- `com.apple.security.application-groups`: `group.app.bsky` (for data sharing) +- `com.apple.developer.parent-application-identifiers`: Links to the main app bundle ID +- `com.apple.developer.associated-domains`: Inherits from main app config (for universal links) + +### Build Settings + +- Deployment target: iOS 15.1+ +- Bundle ID: `[main-app-bundle-id].AppClip` +- Product type: `com.apple.product-type.application.on-demand-install-capable` +- Development team: `B3LX46C5HS` +- Device family: iPhone only (1) + +## Platform Support + +- **iOS**: Full support via native App Clip +- **Android**: Not applicable (no App Clip equivalent) +- **Web**: Not applicable (web uses standard starter pack landing pages) + +## Integration with Main App + +The main app detects App Clip-originated starter packs through `useStarterPackEntry` hook: + +**Native** (`src/components/hooks/useStarterPackEntry.native.ts`): +- Reads `starterPackUri` from `SharedPrefs` (App Group) +- Clears the value after reading to prevent re-use +- Sets active starter pack in app state + +**Web** (`src/components/hooks/useStarterPackEntry.ts`): +- Detects `?clip=true` URL parameter +- Extracts starter pack URI from URL +- Sets active starter pack with `isClip: true` flag + +## Files + +``` +modules/BlueskyClip/ +├── AppDelegate.swift # App lifecycle and URL handling +├── ViewController.swift # WebView management and message handling +└── Images.xcassets/ # App Clip icon assets + ├── AppIcon.appiconset/ + │ ├── App-Icon-1024x1024@1x.png + │ └── Contents.json + └── Contents.json +``` + +## Development Notes + +- The App Clip is built as part of the main Xcode project when running `yarn prebuild` +- Source files are copied during the prebuild process, not directly referenced +- Changes to Swift files require running `yarn prebuild` to take effect +- The App Clip shares the same version number as the main app +- App Clips have a 15MB size limit (enforced by Apple) +- Users can convert an App Clip session into a full app install without losing data (via shared App Group) diff --git a/modules/BlueskyNSE/README.md b/modules/BlueskyNSE/README.md new file mode 100644 index 0000000000..63136141cc --- /dev/null +++ b/modules/BlueskyNSE/README.md @@ -0,0 +1,135 @@ +# BlueskyNSE + +BlueskyNSE is an iOS Notification Service Extension that processes push notifications before they are displayed to the user. NSE stands for "Notification Service Extension", a native iOS app extension type. + +## What It Does + +This extension intercepts incoming push notifications and performs processing before displaying them: + +1. Manages badge counts for app icon +2. Applies custom notification sounds based on user preferences +3. Enables notification customization without requiring the main app to be running + +## How It Works + +When a push notification arrives on iOS, the system can invoke this extension to modify the notification content before displaying it. The extension runs in a separate process from the main app and has strict time limits (approximately 30 seconds) to complete its work. + +### Architecture + +The extension uses shared UserDefaults (via App Groups) to access preferences set by the main app: + +- **App Group**: `group.app.bsky` allows data sharing between the main app and the extension +- **Shared Preferences**: Stored in UserDefaults suite accessible by both processes +- **Thread Safety**: Uses a dedicated serial DispatchQueue (`NSEPrefsQueue`) to prevent race conditions when multiple notifications arrive simultaneously + +### Notification Processing Flow + +1. System receives push notification +2. `NotificationService.didReceive()` is called +3. Extension creates mutable copy of notification content +4. Based on notification type (determined by `reason` field): + - **Chat messages** (`reason == "chat-message"`): Applies custom DM sound if user preference `playSoundChat` is enabled + - **Other notifications**: Increments and applies badge count +5. Extension delivers modified notification to system via `contentHandler` + +### Badge Count Management + +Badge counts are managed centrally by the extension: +- Each non-chat notification increments the badge count +- Count is synchronized across notification instances using the serial queue +- Main app can reset the count via the `expo-background-notification-handler` module + +### Notification Sounds + +Two sound types are supported: +- **Default system sound**: Standard iOS notification sound +- **DM sound**: Custom `dm.aiff` sound file for chat messages + +DM sound only plays if the user has enabled the `playSoundChat` preference in the main app's chat settings. + +## Key Files + +| File | Purpose | +|------|---------| +| `NotificationService.swift` | Main service extension implementation | +| `BlueskyNSE.entitlements` | iOS entitlements configuration for App Group access | +| `Info.plist` | Extension metadata and configuration | + +### NotificationService.swift + +Contains two main classes: + +**NotificationService**: The main extension class that implements `UNNotificationServiceExtension` +- `didReceive(_:withContentHandler:)`: Processes incoming notifications +- `serviceExtensionTimeWillExpire()`: Handles timeout scenarios +- Mutation methods for modifying notification content + +**NSEUtil**: Singleton utility class for shared state management +- Provides shared `UserDefaults` instance for the App Group +- Manages serial queue for thread-safe preference access +- Helper methods for notification content manipulation + +## Configuration + +### App Group Setup + +The extension requires the `group.app.bsky` App Group to be configured in: +1. Main app target capabilities +2. Extension target capabilities (defined in `BlueskyNSE.entitlements`) + +### Shared Preferences + +The following preferences are shared between the main app and extension: + +| Preference Key | Type | Purpose | +|----------------|------|---------| +| `badgeCount` | Int | Current badge count for app icon | +| `playSoundChat` | Bool | Whether to play sound for chat notifications | + +These are managed by the `expo-background-notification-handler` module in the main app. + +### Sound Files + +The custom DM sound file (`dm.aiff`) must be included in the extension's bundle. The iOS project configuration handles copying this resource during the build. + +## Platform Support + +- **iOS**: Fully supported (primary platform for this extension) +- **Android**: Not applicable (Android uses different notification handling mechanisms) +- **Web**: Not applicable (web notifications are handled by browser APIs) + +## Integration with Main App + +The extension coordinates with the main app through: + +1. **expo-background-notification-handler** module: Provides JavaScript API for managing shared preferences +2. **App Group shared storage**: Enables data synchronization between processes +3. **Push notification payload**: Must include `reason` field to determine notification type + +### Setting User Preferences + +Users can control notification sounds via the Chat Settings screen (`src/screens/Messages/Settings.tsx`): + +```typescript +import {useBackgroundNotificationPreferences} from '../../../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' + +const {preferences, setPref} = useBackgroundNotificationPreferences() +setPref('playSoundChat', true) // Enable DM sounds +``` + +## Limitations + +1. **Time constraints**: Extension must complete processing within ~30 seconds or the system will terminate it +2. **Process isolation**: Runs in separate process with limited memory and resources +3. **iOS only**: Notification Service Extensions are an iOS-specific feature +4. **Concurrent processing**: Multiple notifications may arrive simultaneously, requiring careful state management + +## Best Practices + +When modifying this extension: + +1. Keep processing fast and synchronous when possible +2. Use the shared serial queue for any UserDefaults mutations +3. Avoid network requests that could cause timeouts +4. Always call `contentHandler` with modified content, even on errors +5. Test with multiple concurrent notifications to verify thread safety diff --git a/modules/Share-with-Bluesky/README.md b/modules/Share-with-Bluesky/README.md new file mode 100644 index 0000000000..9ba7a1314b --- /dev/null +++ b/modules/Share-with-Bluesky/README.md @@ -0,0 +1,140 @@ +# Share-with-Bluesky + +iOS Share Extension for the Bluesky Social app that enables users to share content from other apps directly to Bluesky. + +## Overview + +This module implements an iOS Share Extension (Action Extension) that appears in the system share sheet when users tap the share button in other iOS apps. It allows sharing text, URLs, images, and videos to create a new Bluesky post. + +## Features + +- Share plain text +- Share URLs (web links) +- Share images (up to 4 images, supports PNG, JPG, JPEG, GIF, HEIC) +- Share videos (single video, supports MOV, MP4, M4V) +- Automatic image dimension extraction +- Automatic video dimension extraction +- App group file sharing for media access + +## Architecture + +### iOS Share Extension + +The extension is implemented as a native iOS Share Extension using Swift. When a user shares content: + +1. The `ShareViewController` receives the shared content from the extension context +2. Content is processed based on its type (text, URL, image, or video) +3. Media files are copied to a shared App Group container (`group.app.bsky`) for access by the main app +4. Image and video dimensions are extracted and encoded into the URI +5. The extension constructs a deep link URL with the content encoded in query parameters +6. The main Bluesky app is opened with the deep link +7. The extension completes and dismisses + +### Deep Link Format + +The extension communicates with the main app using deep links with the `bluesky://` scheme: + +``` +bluesky://intent/compose?text= +bluesky://intent/compose?imageUris=||,|| +bluesky://intent/compose?videoUri=|| +``` + +The scheme can be customized by setting the `MainAppScheme` key in `Info.plist` to support forks. + +### Main App Integration + +The main app handles these deep links in `src/lib/hooks/useIntentHandler.ts`: + +- Parses the deep link parameters +- Validates image/video URIs for security (filters out external URLs) +- Opens the composer with the pre-populated content +- Supports up to 4 images or 1 video per share + +## Key Files + +### Module Files + +- `ShareViewController.swift` - Main view controller that handles share requests and processes content +- `Info.plist` - Extension configuration (activation rules, supported content types) +- `Share-with-Bluesky.entitlements` - App group entitlements for shared file access + +### App Integration + +- `src/lib/hooks/useIntentHandler.ts` - Main app hook that handles incoming deep links +- `android/app/src/main/AndroidManifest.xml` - Android share intent configuration (lines 57-76) + +## Configuration + +### Supported Content Types + +Defined in `Info.plist` under `NSExtensionActivationRule`: + +- Text: Plain text strings +- Web URLs: Up to 1 URL +- Images: Up to 10 images +- Videos: Up to 1 video + +### App Group + +The extension uses the `group.app.bsky` App Group identifier to share files with the main app. This is configured in: + +- `Share-with-Bluesky.entitlements` +- Main app's entitlements file + +### Custom Scheme + +The `MainAppScheme` in `Info.plist` defaults to `bluesky` but can be changed for forks to use a custom URL scheme. + +## Platform Support + +- iOS: Native Share Extension (this module) +- Android: Native share intents handled via MainActivity intent filters in AndroidManifest.xml +- Web: Not applicable (browser share APIs use different mechanisms) + +## Implementation Details + +### Image Processing + +When images are shared: + +1. Images are loaded from the extension's temporary directory or as UIImage objects +2. Images are converted to JPEG format at maximum quality +3. Dimensions are extracted from the UIImage +4. Files are saved to the App Group container with unique names +5. URIs are formatted as `||` + +### Video Processing + +When videos are shared: + +1. Videos are copied from the source URL to the App Group container +2. AVURLAsset is used to extract video track dimensions +3. Track dimensions are adjusted for video rotation using preferredTransform +4. URI is formatted as `||` + +### Security + +- External URLs in image URIs are filtered out in the main app to prevent potential security issues +- Only file:// URLs from the App Group container are accepted +- URI format is validated with a regex pattern before processing + +## Development + +This module is built as part of the main Xcode project. The extension target is included in the iOS build configuration. + +To modify the extension: + +1. Open the Xcode project in `/ios` +2. Navigate to the Share-with-Bluesky target +3. Edit `ShareViewController.swift` for logic changes +4. Edit `Info.plist` for configuration changes +5. Rebuild the iOS app + +## Limitations + +- Images: Maximum of 4 images per share (limited in main app handler) +- Videos: Only 1 video per share +- Mixed media: Cannot share images and videos together +- File size: No explicit limits, but large files may cause issues +- Formats: Only supports common image/video formats listed in constants diff --git a/modules/bottom-sheet/README.md b/modules/bottom-sheet/README.md new file mode 100644 index 0000000000..49007d97b5 --- /dev/null +++ b/modules/bottom-sheet/README.md @@ -0,0 +1,248 @@ +# Bottom Sheet Expo Module + +A custom Expo module that provides native bottom sheet functionality for iOS and Android, using platform-specific native bottom sheet implementations (UISheetPresentationController on iOS, Material BottomSheetDialog on Android). + +## Overview + +This module wraps native bottom sheet components to provide a React Native interface with cross-platform consistency. It uses native presentation APIs rather than JavaScript-based animations for better performance and native behavior. + +Key features: +- Native bottom sheet presentation on iOS and Android +- Automatic content height detection (no JS bridge round-trip) +- Configurable snap points (hidden, partial, full) +- Drag-to-dismiss with prevention controls +- Portal-based rendering for proper z-index layering +- Edge-to-edge support on modern Android versions +- iOS 26+ zoom transition support + +## Platform Support + +- **iOS**: Uses `UISheetPresentationController` (iOS 15+) +- **Android**: Uses Material Design `BottomSheetDialog` with `BottomSheetBehavior` +- **Web**: Not supported (throws error) + +## Architecture + +### TypeScript Layer + +The module exposes a React component that handles rendering and state management: + +- **BottomSheet.tsx** (Native): Main component wrapping the native view +- **BottomSheet.web.tsx** (Web): Stub that throws an error +- **BottomSheetNativeComponent.tsx**: React wrapper with portal integration +- **BottomSheetPortal.tsx**: Portal system for rendering sheets above app content +- **Portal.tsx**: Generic portal implementation for managing component hierarchy + +The component uses a class-based approach to expose imperative methods (`present()`, `dismiss()`, `dismissAll()`). + +### Native Layer + +#### iOS Implementation + +- **BottomSheetModule.swift**: Expo module definition with event handlers and prop bindings +- **SheetView.swift**: Main view component that creates and manages `SheetViewController` + - Observes content height via KVO (Key-Value Observing) on bounds + - Manages sheet lifecycle and state transitions + - Implements `UISheetPresentationControllerDelegate` for drag events +- **SheetViewController.swift**: UIViewController subclass with sheet presentation + - Configures detents (snap points) based on content height + - Handles iOS 26+ safe area adjustments for floating sheet style + - Animates detent changes when content resizes +- **SheetManager.swift**: Singleton that tracks all active sheets with weak references +- **Util.swift**: Helper for calculating screen height minus safe area insets + +#### Android Implementation + +- **BottomSheetModule.kt**: Expo module definition mirroring iOS functionality +- **BottomSheetView.kt**: Main view component managing Material BottomSheetDialog + - Uses `OnLayoutChangeListener` to observe content height natively + - Configures `BottomSheetBehavior` for drag and snap behavior + - Handles edge-to-edge display across Android versions (API 29-35+) + - Preserves status/nav bar appearance from host activity +- **DialogRootViewGroup.kt**: Custom ViewGroup acting as RootView for the dialog + - Forwards touch events to React Native event system + - Updates shadow node size to match window dimensions + - Based on React Native's ReactModalHostView pattern +- **SheetManager.kt**: Singleton for tracking sheets (same pattern as iOS) + +### Content Height Detection + +Both platforms detect content height changes natively without JS bridge round-trips: + +- **iOS**: KVO observation on the content view's `bounds` property +- **Android**: `OnLayoutChangeListener` on child views (catches React Native's direct `layout()` calls) + +This eliminates layout jank when content changes (e.g., keyboard appearance, dynamic content loading). + +## Props + +```typescript +interface BottomSheetViewProps { + children: React.ReactNode + + // Appearance + cornerRadius?: number + backgroundColor?: ColorValue + containerBackgroundColor?: ColorValue + + // Behavior + preventDismiss?: boolean // Disable swipe-to-dismiss + preventExpansion?: boolean // Lock to initial height (no full-screen) + disableDrag?: boolean // Disable drag handle (Android only) + fullHeight?: boolean // Start at full screen height + + // Height constraints + minHeight?: number // Minimum height in dp + maxHeight?: number // Maximum height in dp + + // iOS 26+ transition + sourceViewTag?: number // View tag for zoom transition origin + + // Events + onAttemptDismiss?: (event: BottomSheetAttemptDismissEvent) => void + onSnapPointChange?: (event: BottomSheetSnapPointChangeEvent) => void + onStateChange?: (event: BottomSheetStateChangeEvent) => void +} +``` + +## States and Snap Points + +### States +- `closed`: Sheet is dismissed +- `closing`: Sheet is animating closed +- `open`: Sheet is fully visible +- `opening`: Sheet is animating open + +### Snap Points +- `Hidden` (0): Dismissed +- `Partial` (1): Half-expanded / content height +- `Full` (2): Expanded to screen height + +## Usage + +### Basic Example + +```tsx +import {BottomSheet, BottomSheetProvider, BottomSheetOutlet} from '@modules/bottom-sheet' + +// In your app root: +function App() { + return ( + + + + + ) +} + +// In a component: +function MyComponent() { + const sheetRef = useRef(null) + + const openSheet = () => { + sheetRef.current?.present() + } + + const closeSheet = () => { + sheetRef.current?.dismiss() + } + + return ( + <> + ) diff --git a/src/view/com/composer/select-language/SuggestedLanguage.tsx b/src/view/com/composer/select-language/SuggestedLanguage.tsx index acb35d6d8f..b8e067ffe2 100644 --- a/src/view/com/composer/select-language/SuggestedLanguage.tsx +++ b/src/view/com/composer/select-language/SuggestedLanguage.tsx @@ -1,28 +1,110 @@ -import {useEffect, useState} from 'react' -import {Text as RNText, View} from 'react-native' -import {parseLanguage} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' -import lande from 'lande' +import {useEffect, useMemo, useRef, useState} from 'react' +import {Platform, Text as RNText, View} from 'react-native' +import {RichText} from '@atproto/api' +import {parseLanguageString} from '@atproto/syntax' +import { + guessLanguageAsync, + type LanguageResult, +} from '@bsky.app/expo-guess-language' +import {Trans, useLingui} from '@lingui/react/macro' +import debounce from 'lodash.debounce' -import {code3ToCode2Strict, codeToLanguageName} from '#/locale/helpers' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {useNonReactiveObject} from '#/lib/hooks/useNonReactiveObject' +import {deviceLanguageCodes} from '#/locale/deviceLocales' +import {codeToLanguageName} from '#/locale/helpers' import {useLanguagePrefs} from '#/state/preferences/languages' -import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonText} from '#/components/Button' +import {atoms as a, platform, useTheme} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' +import {IS_WEB} from '#/env' -// fallbacks for safari -const onIdle = - globalThis.requestIdleCallback || ((cb: () => void) => setTimeout(cb, 1)) -const cancelIdle = globalThis.cancelIdleCallback || clearTimeout +type LanguageDetectionPerLanguageConfig = { + acceptanceThreshold?: number + deviceLocaleAcceptanceThreshold?: number +} + +type LanguageDetectionConfig = { + acceptanceThreshold: number + deviceLocaleAcceptanceThreshold: number + overrides: Record +} + +const MIN_TEXT_LENGTH = IS_WEB ? 20 : 10 +const NOISE_FLOOR = 0.1 + +/** + * Platform-resolved defaults. Web uses `lande` under the hood, which + * spreads probability across many candidates — so both the noise floor + * and the acceptance bar sit higher than on native (MLKit). + * + * Per-language carve-outs override the platform-level acceptance + * threshold. + */ +const DEFAULT_CONFIG: LanguageDetectionConfig = { + acceptanceThreshold: platform({ + web: 0.97, + ios: 0.9, + android: 0.9, + default: 0.97, + }), + /* + * Device locales are an independent prior — the OS tells us which + * languages the user has installed, separate from what the model sees + * in the text. Combining the two lets us accept a candidate at lower + * model confidence when the language is one the user actually reads. + * It also fails softer: a wrong suggestion for a language the user + * knows ("are you writing in Spanish?") is easier to dismiss than one + * for a language they don't ("are you writing in Japanese?"), so we + * can afford to be more aggressive there. + * + * Native-only. On web we keep the bar at 0.97 because (a) lande's + * confidence is tightly bimodal — a score of 0.85 means the model + * doesn't know, not that it's "mostly sure" — and (b) the browser's + * locale signal is noisier (navigator.languages usually includes + * English regardless of what the user actually reads). + */ + deviceLocaleAcceptanceThreshold: platform({ + web: 0.97, + ios: 0.8, + android: 0.8, + default: 0.97, + }), + /* + * Per-language carve-outs for known confusable pairs / clusters. The + * acceptance bar is raised above the platform baseline because these + * are languages the detector (especially `lande` on web) is known to + * misclassify or over-commit on. + * + * The device-locale bar is also raised for most tightly-confusable + * pairs: if the user has both languages in the pair installed (common + * for id/ms or nb/da speakers), the device-locale prior no longer + * discriminates between them, so we can't afford to drop the bar as + * aggressively. + * + * Each value uses `platform({web, default})` — `default` applies to + * iOS/Android/etc. (MLKit is better at these distinctions, so the + * bump above baseline is smaller). + */ + overrides: { + // Example + // id: { + // acceptanceThreshold: platform({web: 0.99, default: 0.95}), + // deviceLocaleAcceptanceThreshold: platform({web: 0.97, default: 0.9}), + // }, + }, +} export function SuggestedLanguage({ text, replyToLanguages: replyToLanguagesProp, currentLanguages, onAcceptSuggestedLanguage, + onNudge, }: { text: string /** @@ -39,94 +121,181 @@ export function SuggestedLanguage({ * only suggest the first one. */ onAcceptSuggestedLanguage: (language: string | null) => void + /** + * Fired when detection produced ambiguous results — no strong suggestion + * to show, but we want to hint to the user that the detector is unsure. + * Expected to be an incrementing counter setter on the parent so the + * nudge can re-fire on each detection cycle. + */ + onNudge?: () => void }) { - const langPrefs = useLanguagePrefs() - const replyToLanguages = replyToLanguagesProp - .map(lang => cleanUpLanguage(lang)) - .filter(Boolean) as string[] + const ax = useAnalytics() const [hasInteracted, setHasInteracted] = useState(false) - const [suggestedLanguage, setSuggestedLanguage] = useState< - string | undefined - >(undefined) + const [suggLang, setSuggLang] = useState(undefined) + const declinedSuggLangsRef = useRef([]) + + /* + * Shared callbacks + */ + const onAccept = (language: string) => { + onAcceptSuggestedLanguage(language) + // clear + setSuggLang(undefined) + } + const onDecline = () => { + if (suggLang) { + declinedSuggLangsRef.current.push(suggLang) + // clear + setSuggLang(undefined) + } + } + + /** + * Merge in remote config (eventually) + */ + const config = useMemo(() => DEFAULT_CONFIG, []) + + /** + * Create non-reactive ref for debounced detection method. + */ + const detectionPropsRef = useNonReactiveObject({ + config, + currentLanguages, + }) + + /* + * Held in a ref so the debounced detection closure always sees the + * latest callback identity without rebuilding the debounce timer. + */ + const handleOnNudge = useNonReactiveCallback(onNudge) + + /* + * Main language detection effect + */ + const detectLanguage = useMemo(() => { + return debounce(async (text: string) => { + try { + const currLangs = detectionPropsRef.current.currentLanguages + const {certain, uncertain} = await guessLanguage( + text, + detectionPropsRef.current.config, + ) + const topCandidate = certain.at(0)?.language + if ( + certain.length === 1 && + uncertain.length === 0 && + topCandidate !== undefined && + !currLangs.includes(topCandidate) && + !declinedSuggLangsRef.current.includes(topCandidate) + ) { + // we have a single confident candidate with no competitors — show it! + setSuggLang(topCandidate) + } else { + const nextBestCandidate = uncertain.at(0)?.language + // ambiguous results — if the top candidate isn't already + // selected or previously declined, nudge the user + if ( + nextBestCandidate !== undefined && + !currLangs.includes(nextBestCandidate) && + !declinedSuggLangsRef.current.includes(nextBestCandidate) + ) { + handleOnNudge() + ax.metric('composer:language:nudgeUser', { + os: Platform.OS, + suggestedLanguage: nextBestCandidate, + currentTargetLanguages: currLangs, + textLength: text.length, + }) + } + + setSuggLang(undefined) + } + } catch (e) { + ax.logger.error('Error detecting language', {safeMessage: e}) + } + }, 500) + }, []) useEffect(() => { + // show reply prompt if there's not enough text to start using the model if (text.length > 0 && !hasInteracted) { setHasInteracted(true) } - }, [text, hasInteracted]) - useEffect(() => { - const textTrimmed = text.trim() + if (ax.features.enabled(ax.features.ComposerLanguageDetectionEnable)) { + const textTrimmed = sanitizeTextForDetection(text) - // Don't run the language model on small posts, the results are likely - // to be inaccurate anyway. - if (textTrimmed.length < 40) { - setSuggestedLanguage(undefined) - return + /* + * If text drops under the min length requirement, reset suggestions state + * objects. + * + * And we don't run the language model on small posts, the results are + * likely to be inaccurate. + */ + if (textTrimmed.length < MIN_TEXT_LENGTH) { + setSuggLang(undefined) + return + } + + void detectLanguage(textTrimmed) } - const idle = onIdle(() => { - setSuggestedLanguage(guessLanguage(textTrimmed)) - }) + // Cancel any pending debounced invocation on unmount / re-run so we + // don't call setSuggLang after the composer has closed (or after the + // user has already accepted a language). + return () => { + detectLanguage.cancel() + } + }, [text, hasInteracted, detectLanguage, ax]) - return () => cancelIdle(idle) - }, [text]) + /* + * This is intentionally computed based on a ref. Since we set and clear + * `suggLang` this derivation is safe, but be aware of it + * when making changes. + */ + const hasDeclined = suggLang + ? // eslint-disable-next-line react-hooks/refs + declinedSuggLangsRef.current.includes(suggLang) + : false /* * We've detected a language, and the user hasn't already selected it. */ - const hasLanguageSuggestion = - suggestedLanguage && !currentLanguages.includes(suggestedLanguage) + const hasLanguageSuggestion = suggLang && !currentLanguages.includes(suggLang) + /* * We have not detected a different language, and the user is not already * using or has not already selected one of the languages of the post they * are replying to. */ + const replyToLanguages = replyToLanguagesProp + .filter(Boolean) + .map(lang => parseLanguageString(lang)?.language) + .filter(Boolean) as string[] const hasSuggestedReplyLanguage = !hasInteracted && - !suggestedLanguage && + !suggLang && replyToLanguages.length && !replyToLanguages.some(l => currentLanguages.includes(l)) - if (hasLanguageSuggestion) { - const suggestedLanguageName = codeToLanguageName( - suggestedLanguage, - langPrefs.appLanguage, - ) - + if (hasDeclined) { + return null + } else if (hasLanguageSuggestion) { return ( - - - Are you writing in{' '} - {suggestedLanguageName}? - - - } - value={suggestedLanguage} - onAccept={onAcceptSuggestedLanguage} + ) } else if (hasSuggestedReplyLanguage) { - const suggestedLanguageName = codeToLanguageName( - replyToLanguages[0], - langPrefs.appLanguage, - ) - return ( - - - The post you're replying to was marked as being written in{' '} - {suggestedLanguageName} by its author. Would you like to reply in{' '} - {suggestedLanguageName}? - - - } - value={replyToLanguages[0]} - onAccept={onAcceptSuggestedLanguage} + ) } else { @@ -134,17 +303,137 @@ export function SuggestedLanguage({ } } +function GuessedLanguage({ + language, + metadata, + onAccept: onAcceptOuter, + onDecline: onDeclineOuter, +}: { + language: string + metadata: { + currentTargetLanguages: string[] + rawText: string + } + onAccept: (language: string) => void + onDecline: () => void +}) { + const ax = useAnalytics() + const langPrefs = useLanguagePrefs() + const suggestedLanguageName = codeToLanguageName( + language, + langPrefs.appLanguage, + ) + const onAccept = () => { + ax.metric('composer:language:acceptSuggestion', { + os: Platform.OS, + suggestedLanguage: language, + currentTargetLanguages: metadata.currentTargetLanguages, + textLength: sanitizeTextForDetection(metadata.rawText).length, + }) + onAcceptOuter(language) + } + const onDecline = () => { + ax.metric('composer:language:declineSuggestion', { + os: Platform.OS, + suggestedLanguage: language, + currentTargetLanguages: metadata.currentTargetLanguages, + textLength: sanitizeTextForDetection(metadata.rawText).length, + }) + onDeclineOuter() + } + + const metaRef = useNonReactiveObject(metadata) + useEffect(() => { + ax.metric('composer:language:suggestLanguage', { + os: Platform.OS, + suggestedLanguage: language, + currentTargetLanguages: metaRef.current.currentTargetLanguages, + textLength: sanitizeTextForDetection(metadata.rawText).length, + }) + }, [ax, language]) + + return ( + + + Are you writing in{' '} + {suggestedLanguageName}? + + + } + value={language} + onAccept={onAccept} + onDecline={onDecline} + /> + ) +} + +function ReplyLanguageNudge({ + language, + metadata, + onAccept: onAcceptOuter, + onDecline: onDeclineOuter, +}: { + language: string + metadata: { + currentTargetLanguages: string[] + } + onAccept: (language: string) => void + onDecline: () => void +}) { + const ax = useAnalytics() + const langPrefs = useLanguagePrefs() + const suggestedLanguageName = codeToLanguageName( + language, + langPrefs.appLanguage, + ) + const onAccept = () => { + ax.metric('composer:language:replyNudgeAccept', { + replyToLanguage: language, + currentTargetLanguages: metadata.currentTargetLanguages, + }) + onAcceptOuter(language) + } + const onDecline = () => { + ax.metric('composer:language:replyNudgeDecline', { + replyToLanguage: language, + currentTargetLanguages: metadata.currentTargetLanguages, + }) + onDeclineOuter() + } + + return ( + + + The post you’re replying to was marked as being written in{' '} + {suggestedLanguageName} by its author. Would you like to reply in{' '} + {suggestedLanguageName}? + + + } + value={language} + onAccept={onAccept} + onDecline={onDecline} + /> + ) +} + function LanguageSuggestionButton({ label, value, onAccept, + onDecline, }: { label: React.ReactNode value: string onAccept: (language: string | null) => void + onDecline: () => void }) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() return ( @@ -175,12 +464,20 @@ function LanguageSuggestionButton({ + + @@ -188,28 +485,66 @@ function LanguageSuggestionButton({ } /** - * This function is using the lande language model to attempt to detect the language - * We want to only make suggestions when we feel a high degree of certainty - * The magic numbers are based on debugging sessions against some test strings + * Run detection and partition candidates into "certain" (confident enough + * to suggest on their own) and "uncertain" (above the noise floor but not + * confident enough to suggest). Callers decide what to do with the shape: + * a single certain candidate with no uncertain competitors is a strong + * suggestion; everything else is ambiguous. + * + * The acceptance threshold is resolved per candidate with this precedence: + * 1. Per-language override (e.g. maybe `id` requires higher confidence) + * 2. Device-locale bar (lower on native — the user likely writes in a + * language they have installed) + * 3. Platform-level bar */ -function guessLanguage(text: string): string | undefined { - const scores = lande(text).filter(([_lang, value]) => value >= 0.0002) - // if the model has multiple items with a score higher than 0.0002, it isn't certain enough - if (scores.length !== 1) { - return undefined - } - const [lang, value] = scores[0] - // if the model doesn't give a score of 0.97 or above, it isn't certain enough - if (value < 0.97) { - return undefined - } - return code3ToCode2Strict(lang) -} +async function guessLanguage( + text: string, + config: LanguageDetectionConfig, +): Promise<{ + certain: LanguageResult[] + uncertain: LanguageResult[] +}> { + const suggestions = await guessLanguageAsync(text) + const certain: LanguageResult[] = [] + const uncertain: LanguageResult[] = [] -function cleanUpLanguage(text: string | undefined): string | undefined { - if (!text) { - return undefined + for (const suggestion of suggestions) { + const isDeviceLocale = deviceLanguageCodes.includes(suggestion.language) + const override = config.overrides[suggestion.language] + const threshold = isDeviceLocale + ? (override?.deviceLocaleAcceptanceThreshold ?? + config.deviceLocaleAcceptanceThreshold) + : (override?.acceptanceThreshold ?? config.acceptanceThreshold) + + if (suggestion.confidence >= threshold) { + certain.push(suggestion) + } else if (suggestion.confidence >= NOISE_FLOOR) { + uncertain.push(suggestion) + } } - return parseLanguage(text)?.language + return {certain, uncertain} +} + +/** + * Strip any detected facets from the text to improve language detection + * accuracy. For example, URLs and mentions. + * + * Tags are intentionally kept — their word content is usually in the + * post's language and helps detection; the leading `#` is short enough + * not to distort results. + */ +function sanitizeTextForDetection(text: string): string { + const rt = new RichText({text: text.trim()}) + rt.detectFacetsWithoutResolution() + + let sanitized = '' + for (const segment of rt.segments()) { + if (segment.isLink() || segment.isMention() || segment.isTag()) { + continue + } + sanitized += segment.text + } + + return sanitized.trim() } diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index 9736bd5844..f668c4f209 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -2,7 +2,7 @@ import {useRef} from 'react' import {View} from 'react-native' import {Image} from 'expo-image' import {type ImagePickerAsset} from 'expo-image-picker' -import {BlueskyVideoView} from '@haileyok/bluesky-video' +import {BlueskyVideoView} from '@bsky.app/video' import {type CompressedVideo} from '#/lib/media/video/types' import {clamp} from '#/lib/numbers' diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index c86cd245b6..faf17e7168 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -30,7 +30,6 @@ import { } from '#/state/queries/post-feed' import {truncateAndInvalidate} from '#/state/queries/util' import {useSession} from '#/state/session' -import {useSetMinimalShellMode} from '#/state/shell' import {useHeaderOffset} from '#/components/hooks/useHeaderOffset' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' @@ -70,7 +69,6 @@ export function FeedPage({ const queryClient = useQueryClient() const {openComposer} = useOpenComposer() const [isScrolledDown, setIsScrolledDown] = useState(false) - const setMinimalShellMode = useSetMinimalShellMode() const headerOffset = useHeaderOffset() const feedFeedback = useFeedFeedback(feedInfo, hasSession) const scrollElRef = useRef(null) @@ -95,8 +93,7 @@ export function FeedPage({ animated: IS_NATIVE, offset: -headerOffset, }) - setMinimalShellMode(false) - }, [headerOffset, setMinimalShellMode]) + }, [headerOffset]) const onSoftReset = useCallback(() => { const isScreenFocused = diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index 404cda3edb..950dfb3199 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -27,7 +27,6 @@ import {useQueryClient} from '@tanstack/react-query' import {DM_SERVICE_HEADERS, MAX_POST_LINES} from '#/lib/constants' import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue' -import {usePalette} from '#/lib/hooks/usePalette' import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' import {forceLTR} from '#/lib/strings/bidi' @@ -96,10 +95,9 @@ let NotificationFeedItem = ({ hideTopBorder?: boolean }): React.ReactNode => { const queryClient = useQueryClient() - const pal = usePalette('default') const t = useTheme() const {_, i18n} = useLingui() - const [isAuthorsExpanded, setAuthorsExpanded] = useState(false) + const [isAuthorsExpanded, setIsAuthorsExpanded] = useState(false) const itemHref = useMemo(() => { switch (item.type) { case 'post-like': @@ -149,7 +147,7 @@ let NotificationFeedItem = ({ e.preventDefault() e.stopPropagation() } - setAuthorsExpanded(currentlyExpanded => !currentlyExpanded) + setIsAuthorsExpanded(currentlyExpanded => !currentlyExpanded) } const onBeforePress = useCallback(() => { @@ -226,8 +224,8 @@ let NotificationFeedItem = ({ post={item.subject} style={ isHighlighted && { - backgroundColor: pal.colors.unreadNotifBg, - borderColor: pal.colors.unreadNotifBorder, + backgroundColor: t.palette.primary_25, + borderColor: t.palette.primary_100, } } hideTopBorder={hideTopBorder} @@ -581,8 +579,8 @@ let NotificationFeedItem = ({ item.notification.isRead ? undefined : { - backgroundColor: pal.colors.unreadNotifBg, - borderColor: pal.colors.unreadNotifBorder, + backgroundColor: t.palette.primary_25, + borderColor: t.palette.primary_100, }, !hideTopBorder && a.border_t, a.overflow_hidden, diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 052de3bab3..62e7cb2aae 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -12,10 +12,8 @@ import {useQueryClient} from '@tanstack/react-query' import {MAX_POST_LINES} from '#/lib/constants' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' -import {usePalette} from '#/lib/hooks/usePalette' import {makeProfileLink} from '#/lib/routes/links' import {countLines} from '#/lib/strings/helpers' -import {colors} from '#/lib/styles' import { POST_TOMBSTONE, type Shadow, @@ -26,7 +24,7 @@ import {unstableCacheProfileView} from '#/state/queries/profile' import {Link} from '#/view/com/util/Link' import {PostMeta} from '#/view/com/util/PostMeta' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a} from '#/alf' +import {atoms as a, select, useTheme} from '#/alf' import { GalleryBleed, maybeApplyGalleryOffsetStyles, @@ -119,7 +117,7 @@ function PostInner({ onBeforePress?: () => void }) { const queryClient = useQueryClient() - const pal = usePalette('default') + const t = useTheme() const {openComposer} = useOpenComposer() const [limitLines, setLimitLines] = useState( () => countLines(richText?.text) >= MAX_POST_LINES, @@ -164,8 +162,8 @@ function PostInner({ href={itemHref} style={[ styles.outer, - pal.border, - !hideTopBorder && {borderTopWidth: StyleSheet.hairlineWidth}, + t.atoms.border_contrast_low, + !hideTopBorder && a.border_t, style, ]} onBeforePress={onBeforePress} @@ -176,7 +174,20 @@ function PostInner({ setHover(false) }}> - {showReplyLine && } + {showReplyLine && ( + + )} { const urip = new AtUri(uri) return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey) }, [uri]) - const {_} = useLingui() + const {t: l} = useLingui() return ( - - - - + {({hovered}) => ( + <> + - - - - - - - - {/* HACKFIX: Trans isn't working after SDK 53 upgrade -sfn */} - {_(msg`View full thread`)} - + + + + + + + + + + {/* HACKFIX: Trans isn't working after SDK 53 upgrade -sfn */} + {l`View full thread`} + + + )} ) } - -const styles = StyleSheet.create({ - viewFullThread: { - flexDirection: 'row', - gap: 10, - paddingLeft: 18, - }, - viewFullThreadDots: { - width: 42, - alignItems: 'center', - }, -}) diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index c8c4007f02..d9a7f06af6 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -6,7 +6,7 @@ import { useAnimatedScrollHandler, useSharedValue, } from 'react-native-reanimated' -import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video' +import {updateActiveVideoViewAsync} from '@bsky.app/video' import {useDedupe} from '#/lib/hooks/useDedupe' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' diff --git a/src/view/screens/CommunityGuidelines.tsx b/src/view/screens/CommunityGuidelines.tsx index fa21b7b7f4..fa947be3da 100644 --- a/src/view/screens/CommunityGuidelines.tsx +++ b/src/view/screens/CommunityGuidelines.tsx @@ -1,9 +1,7 @@ -import {useCallback} from 'react' import {View} from 'react-native' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' -import {useFocusEffect} from '@react-navigation/native' import {usePalette} from '#/lib/hooks/usePalette' import { @@ -11,7 +9,6 @@ import { type NativeStackScreenProps, } from '#/lib/routes/types' import {s} from '#/lib/styles' -import {useSetMinimalShellMode} from '#/state/shell' import {TextLink} from '#/view/com/util/Link' import {Text} from '#/view/com/util/text/Text' import {ScrollView} from '#/view/com/util/Views' @@ -25,13 +22,6 @@ type Props = NativeStackScreenProps< export const CommunityGuidelinesScreen = (_props: Props) => { const pal = usePalette('default') const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() - - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) return ( diff --git a/src/view/screens/CopyrightPolicy.tsx b/src/view/screens/CopyrightPolicy.tsx index dcaf7fb951..00d7d454fb 100644 --- a/src/view/screens/CopyrightPolicy.tsx +++ b/src/view/screens/CopyrightPolicy.tsx @@ -1,9 +1,7 @@ -import {useCallback} from 'react' import {View} from 'react-native' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' -import {useFocusEffect} from '@react-navigation/native' import {usePalette} from '#/lib/hooks/usePalette' import { @@ -11,7 +9,6 @@ import { type NativeStackScreenProps, } from '#/lib/routes/types' import {s} from '#/lib/styles' -import {useSetMinimalShellMode} from '#/state/shell' import {TextLink} from '#/view/com/util/Link' import {Text} from '#/view/com/util/text/Text' import {ScrollView} from '#/view/com/util/Views' @@ -22,13 +19,6 @@ type Props = NativeStackScreenProps export const CopyrightPolicyScreen = (_props: Props) => { const pal = usePalette('default') const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() - - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) return ( diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index cc06a924c5..5552e88490 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -4,7 +4,6 @@ import {type AppBskyFeedDefs} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' -import {useFocusEffect} from '@react-navigation/native' import debounce from 'lodash.debounce' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' @@ -24,7 +23,6 @@ import { useSearchPopularFeedsMutation, } from '#/state/queries/feed' import {useSession} from '#/state/session' -import {useSetMinimalShellMode} from '#/state/shell' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {FAB} from '#/view/com/util/fab/FAB' import {List, type ListMethods} from '#/view/com/util/List' @@ -126,7 +124,6 @@ export function FeedsScreen(_props: Props) { hasNextPage: hasNextPopularFeedsPage, } = useGetPopularFeedsQuery() const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() const { data: searchResults, mutate: search, @@ -193,12 +190,6 @@ export function FeedsScreen(_props: Props) { fetchNextPopularFeedsPage, ]) - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - const items = useMemo(() => { let slices: FlatlistSlice[] = [] const hasActualSavedCount = diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 12771d2c1f..c5473d17a8 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,5 +1,6 @@ import {useCallback, useEffect, useLayoutEffect, useMemo, useRef} from 'react' import {ActivityIndicator, StyleSheet} from 'react-native' +import {withSpring} from 'react-native-reanimated' import {useFocusEffect} from '@react-navigation/native' import {PROD_DEFAULT_FEED} from '#/lib/constants' @@ -20,7 +21,7 @@ import {type FeedDescriptor, type FeedParams} from '#/state/queries/post-feed' import {usePreferencesQuery} from '#/state/queries/preferences' import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {useSession} from '#/state/session' -import {useSetMinimalShellMode} from '#/state/shell' +import {useMinimalShellMode} from '#/state/shell' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' import {FeedPage} from '#/view/com/feeds/FeedPage' @@ -138,11 +139,16 @@ function HomeScreenReady({ }, [selectedIndex]) const {hasSession} = useSession() - const setMinimalShellMode = useSetMinimalShellMode() + const {headerMode} = useMinimalShellMode() + const showHeader = useCallback(() => { + 'worklet' + headerMode.set(() => withSpring(0, {overshootClamping: true})) + }, [headerMode]) + useFocusEffect( useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), + return () => showHeader() + }, [showHeader]), ) useFocusEffect( @@ -160,7 +166,7 @@ function HomeScreenReady({ const onPageSelected = useCallback( (index: number) => { - setMinimalShellMode(false) + showHeader() const maybeFeed = allFeeds[index] // Mutate the ref before setting state to avoid the imperative syncing effect @@ -176,7 +182,7 @@ function HomeScreenReady({ }) } }, - [ax, setSelectedFeed, setMinimalShellMode, allFeeds], + [ax, setSelectedFeed, showHeader, allFeeds], ) const onPressSelected = useCallback(() => { @@ -187,10 +193,10 @@ function HomeScreenReady({ (state: 'idle' | 'dragging' | 'settling') => { 'worklet' if (state === 'dragging') { - setMinimalShellMode(false) + showHeader() } }, - [setMinimalShellMode], + [showHeader], ) const [demoMode] = useDemoMode() @@ -247,7 +253,6 @@ function HomeScreenReady({ ref={pagerRef} testID="homeScreen" onPageSelected={onPageSelected} - onPageScrollStateChanged={onPageScrollStateChanged} renderTabBar={renderTabBar} initialPage={selectedIndex}> export function ListsScreen({}: Props) { const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() const navigation = useNavigation() const requireEmailVerification = useRequireEmailVerification() const createListDialogControl = useDialogControl() - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - const onPressNewList = useCallback(() => { createListDialogControl.open() }, [createListDialogControl]) diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx index 64103da875..258a3c9b5f 100644 --- a/src/view/screens/ModerationBlockedAccounts.tsx +++ b/src/view/screens/ModerationBlockedAccounts.tsx @@ -2,7 +2,6 @@ import {useCallback, useMemo, useState} from 'react' import {type StyleProp, View, type ViewStyle} from 'react-native' import {type AppBskyActorDefs as ActorDefs} from '@atproto/api' import {Trans} from '@lingui/react/macro' -import {useFocusEffect} from '@react-navigation/native' import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {type CommonNavigatorParams} from '#/lib/routes/types' @@ -10,7 +9,6 @@ import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useMyBlockedAccountsQuery} from '#/state/queries/my-blocked-accounts' -import {useSetMinimalShellMode} from '#/state/shell' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {List} from '#/view/com/util/List' import {atoms as a, useTheme} from '#/alf' @@ -25,7 +23,6 @@ type Props = NativeStackScreenProps< > export function ModerationBlockedAccounts({}: Props) { const t = useTheme() - const setMinimalShellMode = useSetMinimalShellMode() const moderationOpts = useModerationOpts() const [isPTRing, setIsPTRing] = useState(false) @@ -47,12 +44,6 @@ export function ModerationBlockedAccounts({}: Props) { return [] }, [data]) - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - const onRefresh = useCallback(async () => { setIsPTRing(true) try { diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx index d47672d39f..4ef555a6c2 100644 --- a/src/view/screens/ModerationModlists.tsx +++ b/src/view/screens/ModerationModlists.tsx @@ -3,7 +3,7 @@ import {AtUri} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' -import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {useNavigation} from '@react-navigation/native' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import { @@ -11,7 +11,6 @@ import { type NativeStackScreenProps, type NavigationProp, } from '#/lib/routes/types' -import {useSetMinimalShellMode} from '#/state/shell' import {MyLists} from '#/view/com/lists/MyLists' import {atoms as a} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -23,17 +22,10 @@ import * as Layout from '#/components/Layout' type Props = NativeStackScreenProps export function ModerationModlistsScreen({}: Props) { const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() const navigation = useNavigation() const requireEmailVerification = useRequireEmailVerification() const createListDialogControl = useDialogControl() - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - const onPressNewList = useCallback(() => { createListDialogControl.open() }, [createListDialogControl]) diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx index b6b2b7422e..122464d301 100644 --- a/src/view/screens/ModerationMutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -2,7 +2,6 @@ import {useCallback, useMemo, useState} from 'react' import {type StyleProp, View, type ViewStyle} from 'react-native' import {type AppBskyActorDefs as ActorDefs} from '@atproto/api' import {Trans} from '@lingui/react/macro' -import {useFocusEffect} from '@react-navigation/native' import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {type CommonNavigatorParams} from '#/lib/routes/types' @@ -10,7 +9,6 @@ import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useMyMutedAccountsQuery} from '#/state/queries/my-muted-accounts' -import {useSetMinimalShellMode} from '#/state/shell' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {List} from '#/view/com/util/List' import {atoms as a, useTheme} from '#/alf' @@ -26,7 +24,6 @@ type Props = NativeStackScreenProps< export function ModerationMutedAccounts({}: Props) { const t = useTheme() const moderationOpts = useModerationOpts() - const setMinimalShellMode = useSetMinimalShellMode() const [isPTRing, setIsPTRing] = useState(false) const { @@ -47,12 +44,6 @@ export function ModerationMutedAccounts({}: Props) { return [] }, [data]) - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - const onRefresh = useCallback(async () => { setIsPTRing(true) try { diff --git a/src/view/screens/NotFound.tsx b/src/view/screens/NotFound.tsx index a16a35a37d..4f5f2c0915 100644 --- a/src/view/screens/NotFound.tsx +++ b/src/view/screens/NotFound.tsx @@ -3,16 +3,11 @@ import {StyleSheet, View} from 'react-native' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' -import { - StackActions, - useFocusEffect, - useNavigation, -} from '@react-navigation/native' +import {StackActions, useNavigation} from '@react-navigation/native' import {usePalette} from '#/lib/hooks/usePalette' import {type NavigationProp} from '#/lib/routes/types' import {s} from '#/lib/styles' -import {useSetMinimalShellMode} from '#/state/shell' import {Button} from '#/view/com/util/forms/Button' import {Text} from '#/view/com/util/text/Text' import {ViewHeader} from '#/view/com/util/ViewHeader' @@ -22,13 +17,6 @@ export const NotFoundScreen = () => { const pal = usePalette('default') const {_} = useLingui() const navigation = useNavigation() - const setMinimalShellMode = useSetMinimalShellMode() - - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) const canGoBack = navigation.canGoBack() const onPressHome = useCallback(() => { diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index d9888e9e4e..3423d689a7 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -23,7 +23,6 @@ import { useUnreadNotificationsApi, } from '#/state/queries/notifications/unread' import {truncateAndInvalidate} from '#/state/queries/util' -import {useSetMinimalShellMode} from '#/state/shell' import {NotificationFeed} from '#/view/com/notifications/NotificationFeed' import {Pager} from '#/view/com/pager/Pager' import {TabBar} from '#/view/com/pager/TabBar' @@ -187,7 +186,6 @@ function NotificationsTab({ setIsLoadingLatest: (v: boolean) => void }) { const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() const [isScrolledDown, setIsScrolledDown] = useState(false) const scrollElRef = useRef(null) const queryClient = useQueryClient() @@ -198,8 +196,7 @@ function NotificationsTab({ // = const scrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({animated: IS_NATIVE, offset: 0}) - setMinimalShellMode(false) - }, [scrollElRef, setMinimalShellMode]) + }, [scrollElRef]) const onPressLoadLatest = useCallback(() => { scrollToTop() @@ -242,11 +239,10 @@ function NotificationsTab({ useFocusEffect( useCallback(() => { if (isFocusedAndActive) { - setMinimalShellMode(false) logger.debug('NotificationsScreen: Focus') onFocusCheckLatest() } - }, [setMinimalShellMode, onFocusCheckLatest, isFocusedAndActive]), + }, [onFocusCheckLatest, isFocusedAndActive]), ) useEffect(() => { diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index f07c971fb2..8bb0a2b677 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -1,28 +1,16 @@ -import {useCallback} from 'react' -import {useFocusEffect} from '@react-navigation/native' - import { type CommonNavigatorParams, type NativeStackScreenProps, } from '#/lib/routes/types' import {makeRecordUri} from '#/lib/strings/url-helpers' -import {useSetMinimalShellMode} from '#/state/shell' import {PostThread} from '#/screens/PostThread' import * as Layout from '#/components/Layout' type Props = NativeStackScreenProps export function PostThreadScreen({route}: Props) { - const setMinimalShellMode = useSetMinimalShellMode() - const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - return ( diff --git a/src/view/screens/PrivacyPolicy.tsx b/src/view/screens/PrivacyPolicy.tsx index b5ce4ca31a..7853c1bd7a 100644 --- a/src/view/screens/PrivacyPolicy.tsx +++ b/src/view/screens/PrivacyPolicy.tsx @@ -1,9 +1,7 @@ -import {useCallback} from 'react' import {View} from 'react-native' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' -import {useFocusEffect} from '@react-navigation/native' import {usePalette} from '#/lib/hooks/usePalette' import { @@ -11,7 +9,6 @@ import { type NativeStackScreenProps, } from '#/lib/routes/types' import {s} from '#/lib/styles' -import {useSetMinimalShellMode} from '#/state/shell' import {TextLink} from '#/view/com/util/Link' import {Text} from '#/view/com/util/text/Text' import {ScrollView} from '#/view/com/util/Views' @@ -22,13 +19,6 @@ type Props = NativeStackScreenProps export const PrivacyPolicyScreen = (_props: Props) => { const pal = usePalette('default') const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() - - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) return ( diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index d29c9a0420..c1ec149a02 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -34,7 +34,6 @@ import {resetProfilePostsQueries} from '#/state/queries/post-feed' import {useProfileQuery} from '#/state/queries/profile' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useAgent, useSession} from '#/state/session' -import {useSetMinimalShellMode} from '#/state/shell' import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens' import {ProfileLists} from '#/view/com/lists/ProfileLists' import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' @@ -175,7 +174,6 @@ function ProfileScreenLoaded({ }) { const profile = useProfileShadow(profileUnshadowed) const {hasSession, currentAccount} = useSession() - const setMinimalShellMode = useSetMinimalShellMode() const {openComposer} = useOpenComposer() const navigation = useNavigation() const requireEmailVerification = useRequireEmailVerification() @@ -317,11 +315,10 @@ function ProfileScreenLoaded({ useFocusEffect( useCallback(() => { - setMinimalShellMode(false) return listenSoftReset(() => { scrollSectionToTop(currentPage) }) - }, [setMinimalShellMode, currentPage, scrollSectionToTop]), + }, [currentPage, scrollSectionToTop]), ) // events diff --git a/src/view/screens/ProfileFeedLikedBy.tsx b/src/view/screens/ProfileFeedLikedBy.tsx index ce091c2a1d..a34c943ebd 100644 --- a/src/view/screens/ProfileFeedLikedBy.tsx +++ b/src/view/screens/ProfileFeedLikedBy.tsx @@ -1,28 +1,18 @@ -import {useCallback} from 'react' import {Trans} from '@lingui/react/macro' -import {useFocusEffect} from '@react-navigation/native' import { type CommonNavigatorParams, type NativeStackScreenProps, } from '#/lib/routes/types' import {makeRecordUri} from '#/lib/strings/url-helpers' -import {useSetMinimalShellMode} from '#/state/shell' import {PostLikedBy as PostLikedByComponent} from '#/view/com/post-thread/PostLikedBy' import * as Layout from '#/components/Layout' type Props = NativeStackScreenProps export const ProfileFeedLikedByScreen = ({route}: Props) => { - const setMinimalShellMode = useSetMinimalShellMode() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey) - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - return ( diff --git a/src/view/screens/Support.tsx b/src/view/screens/Support.tsx index 31f0a75cb6..3fbad73e40 100644 --- a/src/view/screens/Support.tsx +++ b/src/view/screens/Support.tsx @@ -1,8 +1,6 @@ -import {useCallback} from 'react' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' -import {useFocusEffect} from '@react-navigation/native' import {HELP_DESK_URL} from '#/lib/constants' import {usePalette} from '#/lib/hooks/usePalette' @@ -11,7 +9,6 @@ import { type NativeStackScreenProps, } from '#/lib/routes/types' import {s} from '#/lib/styles' -import {useSetMinimalShellMode} from '#/state/shell' import {TextLink} from '#/view/com/util/Link' import {Text} from '#/view/com/util/text/Text' import {ViewHeader} from '#/view/com/util/ViewHeader' @@ -21,15 +18,8 @@ import * as Layout from '#/components/Layout' type Props = NativeStackScreenProps export const SupportScreen = (_props: Props) => { const pal = usePalette('default') - const setMinimalShellMode = useSetMinimalShellMode() const {_} = useLingui() - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - return ( diff --git a/src/view/screens/TermsOfService.tsx b/src/view/screens/TermsOfService.tsx index ffdb05c2af..08e93188ba 100644 --- a/src/view/screens/TermsOfService.tsx +++ b/src/view/screens/TermsOfService.tsx @@ -1,9 +1,7 @@ -import {useCallback} from 'react' import {View} from 'react-native' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' -import {useFocusEffect} from '@react-navigation/native' import {usePalette} from '#/lib/hooks/usePalette' import { @@ -11,7 +9,6 @@ import { type NativeStackScreenProps, } from '#/lib/routes/types' import {s} from '#/lib/styles' -import {useSetMinimalShellMode} from '#/state/shell' import {TextLink} from '#/view/com/util/Link' import {Text} from '#/view/com/util/text/Text' import {ScrollView} from '#/view/com/util/Views' @@ -21,15 +18,8 @@ import {ViewHeader} from '../com/util/ViewHeader' type Props = NativeStackScreenProps export const TermsOfServiceScreen = (_props: Props) => { const pal = usePalette('default') - const setMinimalShellMode = useSetMinimalShellMode() const {_} = useLingui() - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - return ( diff --git a/yarn.lock b/yarn.lock index ff02962745..edd987d5a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20,10 +20,10 @@ "@jridgewell/gen-mapping" "^0.3.0" "@jridgewell/trace-mapping" "^0.3.9" -"@atproto/api@^0.19.9": - version "0.19.9" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.9.tgz#f09ed8412159d6878eeaf25a0a8b4445c62fa9eb" - integrity sha512-+sUYNuiA1Rv8HemMCURHwRkMp2D7cq6nNquefjosu6UB54IzkD0MLK3YY383poLRShiApouOxRse2OKK25dbQw== +"@atproto/api@^0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.11.tgz#73885a47959907f22b68d671011ee70f80afd44b" + integrity sha512-7V4Sg6hcv/UxoXobjfvy/Ox2ioKQtZ3DzbsiFndYCcBfsZ5GO8rNEroHPq3hT0CFBJK1NAD6JfOtTBN2z267Xg== dependencies: "@atproto/common-web" "^0.4.21" "@atproto/lexicon" "^0.6.2" @@ -101,7 +101,7 @@ multiformats "^9.9.0" zod "^3.23.8" -"@atproto/syntax@^0.5.0", "@atproto/syntax@^0.5.1": +"@atproto/syntax@0.5.2", "@atproto/syntax@^0.5.0", "@atproto/syntax@^0.5.1": version "0.5.2" resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.2.tgz#d4b32c9feb421ceeb5ade1fa80bc42764d51e52e" integrity sha512-W41szOnkppoHr0iCUrzL8gy3OD6qmDyp1UvUgmTx2oFQfgbudpz51T/gznesiCcqiUT5obfHdx4PJ+WdlEOE7Q== @@ -2424,6 +2424,13 @@ dependencies: react-responsive "^10.0.1" +"@bsky.app/expo-guess-language@^0.2.8": + version "0.2.8" + resolved "https://registry.yarnpkg.com/@bsky.app/expo-guess-language/-/expo-guess-language-0.2.8.tgz#e1c2d03b8852eb5fb7397316b0ec8cd7c4f98747" + integrity sha512-krcQfMSJn39kaFRpaOWxLUW9rT04reoBqjQviu2fTGQWXWEImG25SJondSObVNyGXlmRMrltt72Sc+aRPpQeog== + dependencies: + lande "^1.0.10" + "@bsky.app/expo-image-crop-tool@^0.5.0": version "0.5.0" resolved "https://registry.yarnpkg.com/@bsky.app/expo-image-crop-tool/-/expo-image-crop-tool-0.5.0.tgz#4308fbde5c15e6be9122601797bc3d9549c95e31" @@ -2454,6 +2461,11 @@ resolved "https://registry.yarnpkg.com/@bsky.app/tapper/-/tapper-0.5.1.tgz#7c72e1903435290a29be9f33fe0fcba95bbfa554" integrity sha512-roGmW6Fk9qE8N0u9d74XzX9+MUIr04PElOhfIg0pXtZ1buaORpasLBBC+i6WytxDP2p29CuFdzBXDaBhmcI/ow== +"@bsky.app/video@0.3.4": + version "0.3.4" + resolved "https://registry.yarnpkg.com/@bsky.app/video/-/video-0.3.4.tgz#68c626d025f0005313a8320120540f51bed58da7" + integrity sha512-BTHfdS5hWlpBvMSNhYhsAoBnWtu4BCXYyK/DZ1wA/akXquJP3SuzEGL6B2V0Xdtg2E0rhjJD2njlTj69E64reg== + "@crowdin/cli@^4.14.1": version "4.14.1" resolved "https://registry.yarnpkg.com/@crowdin/cli/-/cli-4.14.1.tgz#1239922681235b6b14bcacd4fd622bc2217dd6c5" @@ -3372,11 +3384,6 @@ dependencies: dom-mutator "^0.6.0" -"@haileyok/bluesky-video@0.3.2": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@haileyok/bluesky-video/-/bluesky-video-0.3.2.tgz#44dc3974750a9619a4d72e97a0f1678f335028cd" - integrity sha512-CJIS/4HW+x6xqONDZaUljQgMqaaZQnlFjt0afdCSO6VtFk5Fd/ocW+Tc4UulQ6zcCNiL6Y8HJR8sl/EIWHgPDw== - "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77"