Compare commits

...

13 Commits

Author SHA1 Message Date
Elijah Seed-Arita 3ca61e9daa fix: make logo show in qr code by absolutely positioning svg on top of it (#9373)
* fix: make logo show in qr code by absolutely positioning svg on top of it

* fix: remove log and add explanation comment

* fix: only apply qrcode fix to web
2026-01-14 19:45:04 -06:00
Samuel Newman 58ac9a3e7b Patch video player to add a ton of debug logging (#9657) 2026-01-14 19:18:29 -06:00
Eric Bailey bbab7695f9 Add some device location debug (#9693) 2026-01-14 19:05:33 -06:00
Samuel Newman 26aa7c0d9a Add cashtag support for stock ticker discussions (#9689)
* Add cashtag support for stock ticker discussions

Display cashtags ($TICKER format) as clickable links with dedicated menu actions and search feed, alongside hashtags. Includes composer highlighting and proper formatting.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* Fix cashtag search query to use # prefix

Cashtags need to be searched as "#$BTC" rather than just "$BTC" to work
with the search API.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update @atproto/api to 0.18.14 with cashtag support

The updated package includes native CASHTAG_REGEX detection for stock
ticker symbols like $AAPL and $BTC.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix todo, remove redundant validation

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-01-14 19:03:48 -06:00
estrattonbailey 498a09e797 Nightly source-language update 2026-01-14 22:59:53 +00:00
Eric Bailey 2223babc2b [APP-1759] Live Now Open Beta (#9699)
* Add report dialog to LiveStatus dialog

* Mr Worldwide™

* Bug fix bug fix

* Copy update

* Simplify parsing

* Bump api sdk

* update dev-env

* Update yarn.lock

* Bump api sdk

* Refactor useActorStatus, add disablement and appeal dialog

* Fix types

* Guard against chat profile views

* Go live (disabled)

* Fix types

* Update config and gates

* Add allowed services

* Merge conflicts

* Fix gradient handling for nudge

* Only show nudge on your own profile

* Fix live avi overflow breakage

* Add TODO

* Feedback

* Update nux image

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-01-14 16:57:21 -06:00
Eric Bailey 5922c6622f [APP-1767] Live event feeds (#9696)
* Update env config for new worker setup

* Add liveEvents context

* Add live event feed card to Explore

* Add Discover banner/trending handling, device storage

* Add prefs methods, hook up to Discover banner

* Add settings toggle

* Fix up metrics and mutations

* Simplify undo logic

* Handle previews

* Update overlay color and text color handling

* Update metrics

* Handle live event in feed card

* Show multiple feeds on Explore for bsky team

* Fix dev/preview handling bug

* Add sidebar cards, update types

* Fix type error

* Fix type error

* Feedback

* Blurhash fix

* Copy update

* Dev only debug hook
2026-01-14 15:09:01 -06:00
Alex Benzer e684c21572 Improved client events for feed interactions (#9695) 2026-01-14 12:53:24 -08:00
Samuel Newman 226b19ae43 Reduce startup hang by not querying fonts (#9670)
* query fonts lazily

* use `lazy var`
2026-01-14 12:22:26 -08:00
Samuel Newman cb6e65e313 pin AsyncStorage to v2.2.0 (#9656) 2026-01-14 11:16:49 -08:00
Samuel Newman af92610864 Add comprehensive CLAUDE.md development guide (#9666)
* Add comprehensive CLAUDE.md development guide

Document the codebase architecture, styling system (ALF), component patterns
(Dialog, Menu, Button), i18n with Lingui, state management with TanStack Query,
and navigation patterns to help Claude work effectively in this codebase.

* Add footguns section to CLAUDE.md

Document critical pitfalls including:
- Dialog close callback pattern (control.close(() => ...)) for avoiding
  race conditions with navigation, state updates, and opening other dialogs
- Controlled vs uncontrolled input guidance
- Platform-specific component behavior differences

* Add React Compiler note to footguns section

Document that useMemo/useCallback are unnecessary since React Compiler
handles memoization automatically. Only use them for specific cases like
effect dependencies or non-React library interop.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-14 11:16:34 -08:00
pfrazee ca905eb3e1 Nightly source-language update 2026-01-14 02:52:08 +00:00
Samuel Newman 61c919110e Expose QueryClient for TanStack Query DevTools browser extension (#9678)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 09:56:06 -08:00
61 changed files with 3069 additions and 715 deletions
+5 -2
View File
@@ -34,5 +34,8 @@ EXPO_PUBLIC_SENTRY_DSN=
# Bitdrift API key. If undefined, Bitdrift will be disabled.
EXPO_PUBLIC_BITDRIFT_API_KEY=
# bapp-config web worker URL
BAPP_CONFIG_DEV_URL=
# geolocation web worker URL
GEOLOCATION_DEV_URL=
# live-events web worker URL
LIVE_EVENTS_DEV_URL=
+1
View File
@@ -35,6 +35,7 @@ module.exports = {
'Admonition',
'Admonition.Admonition',
'Toast.Action',
'toast.Action',
'AgeAssuranceAdmonition',
'Span',
'StackedButton',
+591
View File
@@ -0,0 +1,591 @@
# CLAUDE.md - Bluesky Social App Development Guide
This document provides guidance for working effectively in the Bluesky Social app codebase.
## Project Overview
Bluesky Social is a cross-platform social media application built with React Native and Expo. It runs on iOS, Android, and Web, connecting to the AT Protocol (atproto) decentralized social network.
**Tech Stack:**
- React Native 0.81 with Expo 54
- TypeScript
- React Navigation for routing
- TanStack Query (React Query) for data fetching
- Lingui for internationalization
- Custom design system called ALF (Application Layout Framework)
## Essential Commands
```bash
# Development
yarn start # Start Expo dev server
yarn web # Start web version
yarn android # Run on Android
yarn ios # Run on iOS
# Testing & Quality
yarn test # Run Jest tests
yarn lint # Run ESLint
yarn typecheck # Run TypeScript type checking
# Internationalization
yarn intl:extract # Extract translation strings
yarn intl:compile # Compile translations for runtime
# Build
yarn build-web # Build web version
yarn prebuild # Generate native projects
```
## Project Structure
```
src/
├── alf/ # Design system (ALF) - themes, atoms, tokens
├── components/ # Shared UI components (Button, Dialog, Menu, etc.)
├── screens/ # Full-page screen components (newer pattern)
├── view/
│ ├── screens/ # Full-page screens (legacy location)
│ ├── com/ # Reusable view components
│ └── shell/ # App shell (navigation bars, tabs)
├── state/
│ ├── queries/ # TanStack Query hooks
│ ├── preferences/ # User preferences (React Context)
│ ├── session/ # Authentication state
│ └── persisted/ # Persistent storage layer
├── lib/ # Utilities, constants, helpers
├── locale/ # i18n configuration and language files
└── Navigation.tsx # Main navigation configuration
```
## Styling System (ALF)
ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens.
### Basic Usage
```tsx
import {atoms as a, useTheme} from '#/alf'
function MyComponent() {
const t = useTheme()
return (
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
Hello
</Text>
</View>
)
}
```
### Key Concepts
**Static Atoms** - Theme-independent styles imported from `atoms`:
```tsx
import {atoms as a} from '#/alf'
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
```
**Theme Atoms** - Theme-dependent colors from `useTheme()`:
```tsx
const t = useTheme()
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
// t.palette.primary_500, t.palette.negative_400, etc.
```
**Platform Utilities** - For platform-specific styles:
```tsx
import {web, native, ios, android, platform} from '#/alf'
const styles = [
a.p_md,
web({cursor: 'pointer'}),
native({paddingBottom: 20}),
platform({ios: {...}, android: {...}, web: {...}}),
]
```
**Breakpoints** - Responsive design:
```tsx
import {useBreakpoints} from '#/alf'
const {gtPhone, gtMobile, gtTablet} = useBreakpoints()
if (gtMobile) {
// Tablet or desktop layout
}
```
### Naming Conventions
- 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`
- Borders: `border`, `border_t`, `rounded_md`, `rounded_full`
## Component Patterns
### Dialog Component
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
```tsx
import * as Dialog from '#/components/Dialog'
function MyFeature() {
const control = Dialog.useDialogControl()
return (
<>
<Button label="Open" onPress={control.open}>
<ButtonText>Open Dialog</ButtonText>
</Button>
<Dialog.Outer control={control}>
<Dialog.Handle /> {/* Native drag handle */}
<Dialog.ScrollableInner label={_(msg`My Dialog`)}>
<Dialog.Header>
<Dialog.HeaderText>Title</Dialog.HeaderText>
</Dialog.Header>
<Text>Dialog content here</Text>
<Button label="Close" onPress={() => control.close()}>
<ButtonText>Close</ButtonText>
</Button>
</Dialog.ScrollableInner>
</Dialog.Outer>
</>
)
}
```
### Menu Component
Menus render as a dropdown on web and a bottom sheet dialog on native.
```tsx
import * as Menu from '#/components/Menu'
function MyMenu() {
return (
<Menu.Root>
<Menu.Trigger label="Open menu">
{({props}) => (
<Button {...props} label="Menu">
<ButtonIcon icon={DotsHorizontal} />
</Button>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item label="Edit" onPress={handleEdit}>
<Menu.ItemIcon icon={Pencil} />
<Menu.ItemText>Edit</Menu.ItemText>
</Menu.Item>
<Menu.Item label="Delete" onPress={handleDelete}>
<Menu.ItemIcon icon={Trash} />
<Menu.ItemText>Delete</Menu.ItemText>
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
)
}
```
### Button Component
```tsx
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
// Solid primary button (most common)
<Button label="Save" onPress={handleSave} color="primary" size="large">
<ButtonText>Save</ButtonText>
</Button>
// With icon
<Button label="Share" onPress={handleShare} color="secondary" size="small">
<ButtonIcon icon={Share} />
<ButtonText>Share</ButtonText>
</Button>
// Icon-only button
<Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round">
<ButtonIcon icon={X} />
</Button>
// Ghost variant (deprecated - use color prop)
<Button label="Cancel" variant="ghost" color="secondary" size="small">
<ButtonText>Cancel</ButtonText>
</Button>
```
**Button Props:**
- `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`)
### Typography
```tsx
import {Text, H1, H2, P} from '#/components/Typography'
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
<P>Paragraph text with default styling.</P>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text>
// For text with emoji, add the emoji prop
<Text emoji>Hello! 👋</Text>
```
### TextField
```tsx
import * as TextField from '#/components/forms/TextField'
<TextField.LabelText>Email</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={AtSign} />
<TextField.Input
label="Email address"
placeholder="you@example.com"
defaultValue={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
</TextField.Root>
```
## Internationalization (i18n)
All user-facing strings must be wrapped for translation using Lingui.
```tsx
import {msg, Trans, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
function MyComponent() {
const {_} = useLingui()
// Simple strings - use msg() with _() function
const title = _(msg`Settings`)
const errorMessage = _(msg`Something went wrong`)
// Strings with variables
const greeting = _(msg`Hello, ${name}!`)
// Pluralization
const countLabel = _(plural(count, {
one: '# item',
other: '# items',
}))
// JSX content - use Trans component
return (
<Text>
<Trans>Welcome to <Text style={a.font_bold}>Bluesky</Text></Trans>
</Text>
)
}
```
**Commands:**
```bash
yarn intl:extract # Extract new strings to locale files
yarn intl:compile # Compile for runtime (required after changes)
```
## State Management
### TanStack Query (Data Fetching)
```tsx
// src/state/queries/profile.ts
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
// Query key pattern
const RQKEY_ROOT = 'profile'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
// Query hook
export function useProfileQuery({did}: {did: string}) {
const agent = useAgent()
return useQuery({
queryKey: RQKEY(did),
queryFn: async () => {
const res = await agent.getProfile({actor: did})
return res.data
},
staleTime: STALE.MINUTES.FIVE,
enabled: !!did,
})
}
// Mutation hook
export function useUpdateProfile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data) => {
// Update logic
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({queryKey: RQKEY(variables.did)})
},
})
}
```
**Stale Time Constants** (from `src/state/queries/index.ts`):
```tsx
STALE.SECONDS.FIFTEEN // 15 seconds
STALE.MINUTES.ONE // 1 minute
STALE.MINUTES.FIVE // 5 minutes
STALE.HOURS.ONE // 1 hour
STALE.INFINITY // Never stale
```
### Preferences (React Context)
```tsx
// Simple boolean preference pattern
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
function SettingsScreen() {
const autoplayDisabled = useAutoplayDisabled()
const setAutoplayDisabled = useSetAutoplayDisabled()
return (
<Toggle
value={autoplayDisabled}
onValueChange={setAutoplayDisabled}
/>
)
}
```
### Session State
```tsx
import {useSession, useAgent} from '#/state/session'
function MyComponent() {
const {hasSession, currentAccount} = useSession()
const agent = useAgent()
if (!hasSession) {
return <LoginPrompt />
}
// Use agent for API calls
const response = await agent.getProfile({actor: currentAccount.did})
}
```
## Navigation
Navigation uses React Navigation with type-safe route parameters.
```tsx
// Screen component
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
export function ProfileScreen({route, navigation}: Props) {
const {name} = route.params // Type-safe params
return (
<Layout.Screen>
{/* Screen content */}
</Layout.Screen>
)
}
// Programmatic navigation
import {useNavigation} from '@react-navigation/native'
const navigation = useNavigation()
navigation.navigate('Profile', {name: 'alice.bsky.social'})
// Or use the navigate helper
import {navigate} from '#/Navigation'
navigate('Profile', {name: 'alice.bsky.social'})
```
## Platform-Specific Code
Use file extensions for platform-specific implementations:
```
Component.tsx # Shared/default
Component.web.tsx # Web-only
Component.native.tsx # iOS + Android
Component.ios.tsx # iOS-only
Component.android.tsx # Android-only
```
Example from Dialog:
- `src/components/Dialog/index.tsx` - Native (uses BottomSheet)
- `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives)
Platform detection:
```tsx
import {isWeb, isNative, isIOS, isAndroid} from '#/platform/detection'
if (isNative) {
// Native-specific logic
}
```
## Import Aliases
Always use the `#/` alias for absolute imports:
```tsx
// Good
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
// Avoid
import {useSession} from '../../../state/session'
```
## Footguns
Common pitfalls to avoid in this codebase:
### Dialog Close Callback (Critical)
**Always use `control.close(() => ...)` when performing actions after closing a dialog.** The callback ensures the action runs after the dialog's close animation completes. Failing to do this causes race conditions with React state updates.
```tsx
// WRONG - causes bugs with state updates, navigation, opening other dialogs
const onConfirm = () => {
control.close()
navigation.navigate('Home') // May race with dialog animation
}
// WRONG - same problem
const onConfirm = () => {
control.close()
otherDialogControl.open() // Will likely fail or cause visual glitches
}
// CORRECT - action runs after dialog fully closes
const onConfirm = () => {
control.close(() => {
navigation.navigate('Home')
})
}
// CORRECT - opening another dialog after close
const onConfirm = () => {
control.close(() => {
otherDialogControl.open()
})
}
// CORRECT - state updates after close
const onConfirm = () => {
control.close(() => {
setSomeState(newValue)
onCallback?.()
})
}
```
This applies to:
- Navigation (`navigation.navigate()`, `navigation.push()`)
- Opening other dialogs or menus
- State updates that affect UI (`setState`, `queryClient.invalidateQueries`)
- Callbacks passed from parent components
The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`.
### Controlled vs Uncontrolled Inputs
Prefer `defaultValue` over `value` for TextInput on the old architecture:
```tsx
// Preferred - uncontrolled
<TextField.Input
defaultValue={initialEmail}
onChangeText={setEmail}
/>
// Avoid when possible - controlled (can cause performance issues)
<TextField.Input
value={email}
onChangeText={setEmail}
/>
```
### Platform-Specific Behavior
Some components behave differently across platforms:
- `Dialog.Handle` - Only renders on native (drag handle for bottom sheet)
- `Dialog.Close` - Only renders on web (X button)
- `Menu.Divider` - Only renders on web
- `Menu.ContainerItem` - Only works on native
Always test on multiple platforms when using these components.
### React Compiler is Enabled
This codebase uses React Compiler, so **don't proactively add `useMemo` or `useCallback`**. The compiler handles memoization automatically.
```tsx
// UNNECESSARY - React Compiler handles this
const handlePress = useCallback(() => {
doSomething()
}, [doSomething])
// JUST WRITE THIS
const handlePress = () => {
doSomething()
}
```
Only use `useMemo`/`useCallback` when you have a specific reason, such as:
- The value is immediately used in an effect's dependency array
- You're passing a callback to a non-React library that needs referential stability
## Best Practices
1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful
2. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>`
3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles
4. **State**: Use TanStack Query for server state, React Context for UI preferences
5. **Components**: Check if a component exists in `#/components/` before creating new ones
6. **Types**: Define explicit types for props, use `NativeStackScreenProps` for screens
7. **Testing**: Components should have `testID` props for E2E testing
## Key Files Reference
| Purpose | Location |
|---------|----------|
| Theme definitions | `src/alf/themes.ts` |
| Design tokens | `src/alf/tokens.ts` |
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
| Navigation config | `src/Navigation.tsx` |
| Route definitions | `src/routes.ts` |
| Route types | `src/lib/routes/types.ts` |
| Query hooks | `src/state/queries/*.ts` |
| Session state | `src/state/session/index.tsx` |
| i18n setup | `src/locale/i18n.ts` |
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+3 -2
View File
@@ -73,7 +73,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.18.13",
"@atproto/api": "^0.18.15",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.6",
@@ -226,7 +226,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.196",
"@atproto/dev-env": "^0.3.204",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
@@ -284,6 +284,7 @@
"@react-native/babel-preset": "0.81.5",
"@react-native/normalize-colors": "0.81.5",
"**/@expo/image-utils": "0.8.7",
"**/@react-native-async-storage/async-storage": "2.2.0",
"**/expo-constants": "18.0.8",
"**/expo-device": "7.1.4",
"**/zod": "3.23.8",
+116
View File
@@ -18,3 +18,119 @@ index 0000000..3b5b864
@@ -0,0 +1,2 @@
+# Keep FullscreenActivity from being stripped by R8/ProGuard
+-keep class expo.modules.blueskyvideo.FullscreenActivity { *; }
diff --git a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
index fdabd84..eda8c7c 100644
--- a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
+++ b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
@@ -1,8 +1,11 @@
package expo.modules.blueskyvideo
+import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
+import android.os.Build
+import android.util.Log
import android.graphics.Rect
import android.net.Uri
import android.view.ViewGroup
@@ -237,9 +240,44 @@ class BlueskyVideoView(
// Fullscreen handling
fun enterFullscreen(keepDisplayOn: Boolean) {
- val currentActivity = this.appContext.currentActivity ?: return
+ val tag = "BlueskyVideo"
+
+ Log.d(tag, "enterFullscreen() called - keepDisplayOn=$keepDisplayOn")
+ Log.d(tag, " isFullscreen=$isFullscreen, isPlaying=$isPlaying, isMuted=$isMuted")
+ Log.d(tag, " player=${player != null}, url=$url")
+ Log.d(tag, " isAttachedToWindow=$isAttachedToWindow, isShown=$isShown")
+ Log.d(tag, " Android SDK: ${Build.VERSION.SDK_INT}, Device: ${Build.MANUFACTURER} ${Build.MODEL}")
+
+ val currentActivity = this.appContext.currentActivity
+ if (currentActivity == null) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is null")
+ Log.e(tag, " appContext=$appContext")
+ onError(mapOf("error" to "Cannot enter fullscreen: no current activity"))
+ return
+ }
+
+ Log.d(tag, " currentActivity=$currentActivity")
+ Log.d(tag, " activity.isFinishing=${currentActivity.isFinishing}")
+ Log.d(tag, " activity.isDestroyed=${currentActivity.isDestroyed}")
+ Log.d(tag, " activity.lifecycle=${(currentActivity as? androidx.lifecycle.LifecycleOwner)?.lifecycle?.currentState}")
+ Log.d(tag, " activity.hasWindowFocus=${currentActivity.hasWindowFocus()}")
+ Log.d(tag, " activity.window.isActive=${currentActivity.window?.isActive}")
+
+ // Check if activity is in a valid state to start another activity
+ if (currentActivity.isFinishing) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is finishing")
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is finishing"))
+ return
+ }
+
+ if (currentActivity.isDestroyed) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is destroyed")
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is destroyed"))
+ return
+ }
this.enteredFullscreenMuteState = this.isMuted
+ Log.d(tag, " saved enteredFullscreenMuteState=$enteredFullscreenMuteState")
// We always want to start with unmuted state and playing. Fire those from here so the
// event dispatcher gets called
@@ -247,18 +285,51 @@ class BlueskyVideoView(
if (!this.isPlaying) {
this.play()
}
+ Log.d(tag, " after unmute/play: isPlaying=$isPlaying, isMuted=$isMuted")
// Remove the player from this view, but don't null the player!
this.playerView.player = null
+ Log.d(tag, " detached player from playerView")
// create the intent and give it a view
val intent = Intent(context, FullscreenActivity::class.java)
intent.putExtra("keepDisplayOn", keepDisplayOn)
FullscreenActivity.asscVideoView = WeakReference(this)
+ Log.d(tag, " intent created: $intent")
+ Log.d(tag, " intent.component=${intent.component}")
+ Log.d(tag, " intent.flags=${intent.flags} (0x${Integer.toHexString(intent.flags)})")
+ Log.d(tag, " context for intent=$context")
+ Log.d(tag, " FullscreenActivity.asscVideoView set to WeakReference(this)")
+
// fire the fullscreen event and launch the intent
- this.isFullscreen = true
- currentActivity.startActivity(intent)
+ try {
+ Log.d(tag, " calling startActivity()...")
+ currentActivity.startActivity(intent)
+ this.isFullscreen = true
+ Log.d(tag, " startActivity() SUCCESS - isFullscreen set to true")
+ } catch (e: Exception) {
+ Log.e(tag, "enterFullscreen() FAILED: startActivity() threw exception", e)
+ Log.e(tag, " exception class: ${e.javaClass.name}")
+ Log.e(tag, " exception message: ${e.message}")
+ Log.e(tag, " exception cause: ${e.cause}")
+ e.printStackTrace()
+
+ // Restore state since fullscreen failed
+ this.playerView.player = this.player
+ Log.d(tag, " restored player to playerView after failure")
+
+ if (this.enteredFullscreenMuteState) {
+ this.mute()
+ Log.d(tag, " restored mute state after failure")
+ }
+
+ onError(mapOf(
+ "error" to "Failed to enter fullscreen: ${e.message}",
+ "exceptionClass" to e.javaClass.name,
+ "exceptionMessage" to (e.message ?: "unknown")
+ ))
+ }
}
fun onExitFullscreen() {
+16
View File
@@ -0,0 +1,16 @@
diff --git a/node_modules/expo-font/ios/FontLoaderModule.swift b/node_modules/expo-font/ios/FontLoaderModule.swift
index 183480f..7b64f6e 100644
--- a/node_modules/expo-font/ios/FontLoaderModule.swift
+++ b/node_modules/expo-font/ios/FontLoaderModule.swift
@@ -2,10 +2,9 @@ import ExpoModulesCore
public final class FontLoaderModule: Module {
// could be a Set, but to be able to pass to JS we keep it as an array
- private var registeredFonts: [String]
+ private lazy var registeredFonts: [String] = queryCustomNativeFonts()
public required init(appContext: AppContext) {
- self.registeredFonts = queryCustomNativeFonts()
super.init(appContext: appContext)
}
+50 -43
View File
@@ -69,6 +69,10 @@ import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbe
import {ToastOutlet} from '#/components/Toast'
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
import {
prefetchLiveEvents,
Provider as LiveEventsProvider,
} from '#/features/liveEvents/context'
import * as Geo from '#/geolocation'
import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet'
@@ -92,6 +96,7 @@ if (isAndroid) {
*/
Geo.resolve()
prefetchAgeAssuranceConfig()
prefetchLiveEvents()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -141,49 +146,51 @@ function InnerApp() {
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
+46 -39
View File
@@ -57,6 +57,10 @@ import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbe
import {ToastOutlet} from '#/components/Toast'
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
import {
prefetchLiveEvents,
Provider as LiveEventsProvider,
} from '#/features/liveEvents/context'
import * as Geo from '#/geolocation'
import {Splash} from '#/Splash'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -67,6 +71,7 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
*/
Geo.resolve()
prefetchAgeAssuranceConfig()
prefetchLiveEvents()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -117,45 +122,47 @@ function InnerApp() {
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
+6 -1
View File
@@ -11,7 +11,12 @@ import {
import {themes} from '#/alf/themes'
import {type Device} from '#/storage'
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
export {
type TextStyleProp,
type Theme,
utils,
type ViewStyleProp,
} from '@bsky.app/alf'
export {atoms} from '#/alf/atoms'
export * from '#/alf/breakpoints'
export * from '#/alf/fonts'
+8 -2
View File
@@ -359,7 +359,13 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
)
}
export function Handle({difference = false}: {difference?: boolean}) {
export function Handle({
difference = false,
fill,
}: {
difference?: boolean
fill?: string
}) {
const t = useTheme()
const {_} = useLingui()
const {screenReaderEnabled} = useA11y()
@@ -390,7 +396,7 @@ export function Handle({difference = false}: {difference?: boolean}) {
opacity: 0.75,
}
: {
backgroundColor: t.palette.contrast_975,
backgroundColor: fill || t.palette.contrast_975,
opacity: 0.5,
},
]}
+35 -3
View File
@@ -1,4 +1,4 @@
import React from 'react'
import React, {useMemo} from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {
type AppBskyFeedDefs,
@@ -21,19 +21,21 @@ import {
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, select, useTheme} from '#/alf'
import {
Button,
ButtonIcon,
type ButtonProps,
ButtonText,
} from '#/components/Button'
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
import {Link as InternalLink, type LinkProps} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {RichText, type RichTextProps} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {useActiveLiveEventFeedUris} from '#/features/liveEvents/context'
import type * as bsky from '#/types/bsky'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash'
@@ -49,7 +51,11 @@ export function Default(props: Props) {
<Outer>
<Header>
<Avatar src={view.avatar} />
<TitleAndByline title={view.displayName} creator={view.creator} />
<TitleAndByline
title={view.displayName}
creator={view.creator}
uri={view.uri}
/>
<SaveButton view={view} pin />
</Header>
<Description description={view.description} />
@@ -118,14 +124,40 @@ export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
export function TitleAndByline({
title,
creator,
uri,
}: {
title: string
creator?: bsky.profile.AnyProfileView
uri?: string
}) {
const t = useTheme()
const activeLiveEvents = useActiveLiveEventFeedUris()
const liveColor = useMemo(
() =>
select(t.name, {
dark: t.palette.negative_600,
dim: t.palette.negative_600,
light: t.palette.negative_500,
}),
[t],
)
return (
<View style={[a.flex_1]}>
{uri && activeLiveEvents.has(uri) && (
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<LiveIcon size="xs" fill={liveColor} />
<Text
style={[
a.text_2xs,
a.font_medium,
a.leading_snug,
{color: liveColor},
]}>
<Trans>Happening now</Trans>
</Text>
</View>
)}
<Text
emoji
style={[a.text_md, a.font_semi_bold, a.leading_snug]}
+1
View File
@@ -842,6 +842,7 @@ export function SuggestedFeeds() {
<FeedCard.TitleAndByline
title={feed.displayName}
creator={feed.creator}
uri={feed.uri}
/>
</FeedCard.Header>
<FeedCard.Description
+7 -5
View File
@@ -167,6 +167,7 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
a.gap_sm,
a.px_md,
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
@@ -193,7 +194,6 @@ export function ItemText({children, style}: ItemTextProps) {
a.text_md,
a.font_semi_bold,
t.atoms.text_contrast_high,
{paddingTop: 3},
style,
disabled && t.atoms.text_contrast_low,
]}>
@@ -202,16 +202,18 @@ export function ItemText({children, style}: ItemTextProps) {
)
}
export function ItemIcon({icon: Comp}: ItemIconProps) {
export function ItemIcon({icon: Comp, fill}: ItemIconProps) {
const t = useTheme()
const {disabled} = useMenuItemContext()
return (
<Comp
size="lg"
fill={
disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
fill
? fill({disabled})
: disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
}
/>
)
+7 -4
View File
@@ -262,6 +262,7 @@ export function Item({children, label, onPress, style, ...rest}: ItemProps) {
a.gap_lg,
a.py_sm,
a.rounded_xs,
a.overflow_hidden,
{minHeight: 32, paddingHorizontal: 10},
web({outline: 0}),
(hovered || focused) &&
@@ -302,7 +303,7 @@ export function ItemText({children, style}: ItemTextProps) {
)
}
export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
export function ItemIcon({icon: Comp, position = 'left', fill}: ItemIconProps) {
const t = useTheme()
const {disabled} = useMenuItemContext()
return (
@@ -319,9 +320,11 @@ export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
<Comp
size="md"
fill={
disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
fill
? fill({disabled})
: disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
}
/>
</View>
+1
View File
@@ -107,6 +107,7 @@ export type ItemTextProps = React.PropsWithChildren<TextStyleProp & {}>
export type ItemIconProps = React.PropsWithChildren<{
icon: React.ComponentType<SVGIconProps>
position?: 'left' | 'right'
fill?: (props: {disabled: boolean}) => string
}>
export type GroupProps = React.PropsWithChildren<ViewStyleProp & {}>
+3 -3
View File
@@ -17,16 +17,16 @@ export function FeedEmbed({
return (
<FeedCard.Link
view={embed.view}
style={[a.border, t.atoms.border_contrast_low, a.p_md, a.rounded_sm]}>
style={[a.border, t.atoms.border_contrast_low, a.p_sm, a.rounded_md]}>
<FeedCard.Outer>
<FeedCard.Header>
<FeedCard.Avatar src={embed.view.avatar} />
<FeedCard.Avatar src={embed.view.avatar} size={48} />
<FeedCard.TitleAndByline
title={embed.view.displayName}
creator={embed.view.creator}
uri={embed.view.uri}
/>
</FeedCard.Header>
<FeedCard.Likes count={embed.view.likeCount || 0} />
</FeedCard.Outer>
</FeedCard.Link>
)
+16 -7
View File
@@ -48,10 +48,11 @@ export function RichTextTag({
reset: resetRemove,
} = useRemoveMutedWordsMutation()
const navigation = useNavigation<NavigationProp>()
const label = _(msg`Hashtag ${tag}`)
const isCashtag = tag.startsWith('$')
const label = isCashtag ? _(msg`Cashtag ${tag}`) : _(msg`Hashtag ${tag}`)
const hint = isNative
? _(msg`Long press to open tag menu for #${tag}`)
: _(msg`Click to open tag menu for ${tag}`)
? _(msg`Long press to open tag menu for ${isCashtag ? tag : `#${tag}`}`)
: _(msg`Click to open tag menu for ${isCashtag ? tag : `#${tag}`}`)
const isMuted = Boolean(
(preferences?.moderationPrefs.mutedWords?.find(
@@ -109,20 +110,24 @@ export function RichTextTag({
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={_(msg`See ${tag} posts`)}
label={_(msg`See ${isCashtag ? tag : `#${tag}`} posts`)}
onPress={() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
})
}}>
<Menu.ItemText>
<Trans>See #{tag} posts</Trans>
{isCashtag ? (
<Trans>See {tag} posts</Trans>
) : (
<Trans>See #{tag} posts</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={Search} />
</Menu.Item>
{authorHandle && !isInvalidHandle(authorHandle) && (
<Menu.Item
label={_(msg`See ${tag} posts by user`)}
label={_(msg`See ${isCashtag ? tag : `#${tag}`} posts by user`)}
onPress={() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
@@ -130,7 +135,11 @@ export function RichTextTag({
})
}}>
<Menu.ItemText>
<Trans>See #{tag} posts by user</Trans>
{isCashtag ? (
<Trans>See {tag} posts by user</Trans>
) : (
<Trans>See #{tag} posts by user</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={Person} />
</Menu.Item>
+68 -33
View File
@@ -1,4 +1,4 @@
import {lazy} from 'react'
import {lazy, useState} from 'react'
import {View} from 'react-native'
// @ts-expect-error missing types
import QRCode from 'react-native-qrcode-styled'
@@ -102,39 +102,74 @@ export function QrCode({
export function QrCodeInner({link}: {link: string}) {
const t = useTheme()
const [logoArea, setLogoArea] = useState<{
x: number
y: number
width: number
height: number
} | null>(null)
const onLogoAreaChange = (area: {
x: number
y: number
width: number
height: number
}) => {
setLogoArea(area)
}
return (
<QRCode
data={link}
style={[
a.rounded_sm,
{height: 225, width: 225, backgroundColor: '#f3f3f3'},
]}
pieceSize={isWeb ? 8 : 6}
padding={20}
// pieceLiquidRadius={2}
pieceBorderRadius={isWeb ? 4.5 : 3.5}
outerEyesOptions={{
topLeft: {
borderRadius: [12, 12, 0, 12],
color: t.palette.primary_500,
},
topRight: {
borderRadius: [12, 12, 12, 0],
color: t.palette.primary_500,
},
bottomLeft: {
borderRadius: [12, 0, 12, 12],
color: t.palette.primary_500,
},
}}
innerEyesOptions={{borderRadius: 3}}
logo={{
href: require('../../../assets/logo.png'),
scale: 0.95,
padding: 2,
hidePieces: true,
}}
/>
<View style={{position: 'relative'}}>
{/* An SVG version of the logo is placed on top of normal `QRCode` `logo` prop, since the PNG fails to load before the export completes on web. */}
{isWeb && logoArea && (
<View
style={{
position: 'absolute',
left: logoArea.x,
top: logoArea.y + 1,
zIndex: 1,
padding: 4,
}}>
<Logo width={logoArea.width - 14} height={logoArea.height - 14} />
</View>
)}
<QRCode
data={link}
style={[
a.rounded_sm,
{height: 225, width: 225, backgroundColor: '#f3f3f3'},
]}
pieceSize={isWeb ? 8 : 6}
padding={20}
pieceBorderRadius={isWeb ? 4.5 : 3.5}
outerEyesOptions={{
topLeft: {
borderRadius: [12, 12, 0, 12],
color: t.palette.primary_500,
},
topRight: {
borderRadius: [12, 12, 12, 0],
color: t.palette.primary_500,
},
bottomLeft: {
borderRadius: [12, 0, 12, 12],
color: t.palette.primary_500,
},
}}
innerEyesOptions={{borderRadius: 3}}
logo={{
href: require('../../../assets/logo.png'),
...(isWeb && {
onChange: onLogoAreaChange,
padding: 28,
}),
...(!isWeb && {
padding: 2,
scale: 0.95,
}),
hidePieces: true,
}}
/>
</View>
)
}
@@ -0,0 +1,209 @@
import {useCallback, useMemo} 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 {isWeb} from '#/platform/detection'
import {atoms as a, select, useTheme, utils, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
import {
createIsEnabledCheck,
isExistingUserAsOf,
} from '#/components/dialogs/nuxs/utils'
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
import {Text} from '#/components/Typography'
import {IS_E2E} from '#/env'
export const enabled = createIsEnabledCheck(props => {
return (
!IS_E2E &&
isExistingUserAsOf(
'2026-01-16T00:00:00.000Z',
props.currentProfile.createdAt,
) &&
props.gate('live_now_beta')
)
})
export function LiveNowBetaDialog() {
const t = useTheme()
const {_} = useLingui()
const nuxDialogs = useNuxDialogContext()
const control = Dialog.useDialogControl()
Dialog.useAutoOpen(control)
const onClose = useCallback(() => {
nuxDialogs.dismissActiveNux()
}, [nuxDialogs])
const shadowColor = useMemo(() => {
return select(t.name, {
light: utils.alpha(t.palette.primary_900, 0.4),
dark: utils.alpha(t.palette.primary_25, 0.4),
dim: utils.alpha(t.palette.primary_25, 0.4),
})
}, [t])
return (
<Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle fill={t.palette.primary_700} />
<Dialog.ScrollableInner
label={_(msg`Show when youre live`)}
style={[web({maxWidth: 440})]}
contentContainerStyle={[
{
paddingTop: 0,
paddingLeft: 0,
paddingRight: 0,
},
]}>
<View
style={[
a.align_center,
a.overflow_hidden,
{
gap: 16,
paddingTop: isWeb ? 24 : 40,
borderTopLeftRadius: a.rounded_md.borderRadius,
borderTopRightRadius: a.rounded_md.borderRadius,
},
]}>
<LinearGradient
colors={[
t.palette.primary_100,
utils.alpha(t.palette.primary_100, 0),
]}
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]}>
<BeakerIcon fill={t.palette.primary_700} size="sm" />
<Text
style={[
a.font_semi_bold,
{
color: t.palette.primary_700,
},
]}>
<Trans>Beta Feature</Trans>
</Text>
</View>
<View
style={[
a.relative,
a.w_full,
{
paddingTop: 8,
paddingHorizontal: 32,
paddingBottom: 32,
},
]}>
<View
style={[
{
borderRadius: 24,
aspectRatio: 652 / 211,
},
isWeb
? [
{
boxShadow: `0px 10px 15px -3px ${shadowColor}`,
},
]
: [
t.atoms.shadow_md,
{
shadowColor,
shadowOpacity: 0.2,
shadowOffset: {
width: 0,
height: 10,
},
},
],
]}>
<Image
accessibilityIgnoresInvertColors
source={require('../../../../assets/images/live_now_beta.webp')}
style={[
a.w_full,
{
aspectRatio: 652 / 211,
},
]}
alt={_(
msg({
message: `A screenshot of a post from @esb.lol, showing the user is currently livestreaming content on Twitch. The post reads: "Hello! I'm live on Twitch, and I'm testing Bluesky's latest feature too!"`,
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>
</View>
<View style={[a.align_center, a.px_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: isWeb ? 28 : 32,
maxWidth: 360,
},
]}>
<Trans>Show when youre live</Trans>
</Text>
<Text
style={[
a.text_md,
a.leading_snug,
a.text_center,
{
maxWidth: 340,
},
]}>
<Trans>
Streaming on Twitch? Set your live status on Bluesky to add a
badge to your avatar. Tapping it takes people straight to your
stream.
</Trans>
</Text>
</View>
{!isWeb && (
<Button
label={_(msg`Close`)}
size="large"
color="primary"
onPress={() => {
control.close()
}}
style={[a.w_full]}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
)}
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
+6 -8
View File
@@ -20,9 +20,9 @@ import {useProfileQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell'
import {
enabled as isFindContactsAnnouncementEnabled,
FindContactsAnnouncement,
} from '#/components/dialogs/nuxs/FindContactsAnnouncement'
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 {useGeolocation} from '#/geolocation'
@@ -37,8 +37,8 @@ const queuedNuxs: {
enabled?: (props: EnabledCheckProps) => boolean
}[] = [
{
id: Nux.FindContactsAnnouncement,
enabled: isFindContactsAnnouncementEnabled,
id: Nux.LiveNowBetaDialog,
enabled: isLiveNowBetaDialogEnabled,
},
]
@@ -186,9 +186,7 @@ function Inner({
return (
<Context.Provider value={ctx}>
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
{activeNux === Nux.FindContactsAnnouncement && (
<FindContactsAnnouncement />
)}
{activeNux === Nux.LiveNowBetaDialog && <LiveNowBetaDialog />}
</Context.Provider>
)
}
+1 -1
View File
@@ -41,7 +41,7 @@ export function Inner() {
}, [setTrendingDisabled])
return error || noTopics ? null : (
<View style={[t.atoms.border_contrast_low, a.border_t]}>
<View style={[t.atoms.border_contrast_low, a.border_t, a.border_b]}>
<BlockDrawerGesture>
<ScrollView
horizontal
+7 -21
View File
@@ -18,7 +18,6 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {LinkPreview} from './LinkPreview'
@@ -172,26 +171,13 @@ function DialogInner({
</TextField.Root>
</View>
{(liveLinkError || linkMetaError) && (
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
<WarningIcon
style={[{color: t.palette.negative_500}]}
size="sm"
/>
<Text
style={[
a.text_sm,
a.leading_snug,
a.flex_1,
a.font_semi_bold,
{color: t.palette.negative_500},
]}>
{liveLinkError ? (
<Trans>This is not a valid link</Trans>
) : (
cleanError(linkMetaError)
)}
</Text>
</View>
<Admonition type="error">
{liveLinkError ? (
<Trans>This is not a valid link</Trans>
) : (
cleanError(linkMetaError)
)}
</Admonition>
)}
<LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} />
+21 -22
View File
@@ -6,13 +6,14 @@ import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {definitelyUrl} from '#/lib/strings/url-helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useLiveNowConfig} from '#/state/service-config'
import {useTickEveryMinute} from '#/state/shell'
import {atoms as a, ios, native, platform, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {getLiveServiceNames} from '#/components/live/utils'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as Select from '#/components/Select'
@@ -49,6 +50,10 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
const [duration, setDuration] = useState(60)
const moderationOpts = useModerationOpts()
const tick = useTickEveryMinute()
const liveNowConfig = useLiveNowConfig()
const {formatted: allowedServices} = getLiveServiceNames(
liveNowConfig.allowedDomains,
)
const time = useCallback(
(offset: number) => {
@@ -139,27 +144,21 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
/>
</TextField.Root>
</View>
{(liveLinkError || linkMetaError) && (
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
<WarningIcon
style={[{color: t.palette.negative_500}]}
size="sm"
/>
<Text
style={[
a.text_sm,
a.leading_snug,
a.flex_1,
a.font_semi_bold,
{color: t.palette.negative_500},
]}>
{liveLinkError ? (
<Trans>This is not a valid link</Trans>
) : (
cleanError(linkMetaError)
)}
</Text>
</View>
{liveLinkError || linkMetaError ? (
<Admonition type="error">
{liveLinkError ? (
<Trans>This is not a valid link</Trans>
) : (
cleanError(linkMetaError)
)}
</Admonition>
) : (
<Admonition type="tip">
<Trans>
The following services are enabled for your account:{' '}
{allowedServices}
</Trans>
</Admonition>
)}
<LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} />
+8 -7
View File
@@ -18,10 +18,10 @@ import {useLiveNowConfig} from '#/state/service-config'
import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {useDialogContext} from '#/components/Dialog'
import {getLiveServiceNames} from '#/components/live/utils'
export function useLiveLinkMetaQuery(url: string | null) {
const liveNowConfig = useLiveNowConfig()
const {currentAccount} = useSession()
const {_} = useLingui()
const agent = useAgent()
@@ -30,13 +30,14 @@ export function useLiveLinkMetaQuery(url: string | null) {
queryKey: ['link-meta', url],
queryFn: async () => {
if (!url) return undefined
const config = liveNowConfig.find(cfg => cfg.did === currentAccount?.did)
if (!config) throw new Error(_(msg`You are not allowed to go live`))
const urlp = new URL(url)
if (!config.domains.includes(urlp.hostname)) {
throw new Error(_(msg`${urlp.hostname} is not a valid URL`))
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}.`,
),
)
}
return await getLinkMeta(agent, url)
+25
View File
@@ -35,3 +35,28 @@ export function useDebouncedValue<T>(val: T, delayMs: number): T {
return prev
}
const serviceUrlToNameMap: Record<string, string> = {
'twitch.tv': 'Twitch',
'www.twitch.tv': 'Twitch',
'youtube.com': 'YouTube',
'www.youtube.com': 'YouTube',
'youtu.be': 'YouTube',
'nba.com': 'NBA',
'www.nba.com': 'NBA',
'nba.smart.link': 'nba.smart.link',
'espn.com': 'ESPN',
'www.espn.com': 'ESPN',
'stream.place': 'Streamplace',
'skylight.social': 'Skylight',
}
export function getLiveServiceNames(domains: Set<string>) {
const names = Array.from(
new Set(Array.from(domains.values()).map(d => serviceUrlToNameMap[d] || d)),
)
return {
names,
formatted: names.join(', '),
}
}
+15 -5
View File
@@ -108,8 +108,18 @@ export const GCP_PROJECT_ID: number =
* URLs for the app config web worker. Can be a
* locally running server, see `env.example` for more.
*/
export const BAPP_CONFIG_DEV_URL = process.env.BAPP_CONFIG_DEV_URL
export const BAPP_CONFIG_PROD_URL = `https://ip.bsky.app`
export const BAPP_CONFIG_URL = IS_DEV
? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_PROD_URL)
: BAPP_CONFIG_PROD_URL
export const GEOLOCATION_DEV_URL = process.env.GEOLOCATION_DEV_URL
export const GEOLOCATION_PROD_URL = `https://ip.bsky.app`
export const GEOLOCATION_URL = IS_DEV
? (GEOLOCATION_DEV_URL ?? GEOLOCATION_PROD_URL)
: GEOLOCATION_PROD_URL
/**
* URLs for the live-event config web worker. Can be a
* locally running server, see `env.example` for more.
*/
export const LIVE_EVENTS_DEV_URL = process.env.LIVE_EVENTS_DEV_URL
export const LIVE_EVENTS_PROD_URL = `https://live-events.workers.bsky.app`
export const LIVE_EVENTS_URL = IS_DEV
? (LIVE_EVENTS_DEV_URL ?? LIVE_EVENTS_PROD_URL)
: LIVE_EVENTS_PROD_URL
@@ -0,0 +1,89 @@
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useTrendingSettings} from '#/state/preferences/trending'
import {atoms as a, useLayoutBreakpoints} from '#/alf'
import {Button} from '#/components/Button'
import {DotGrid_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
import {TrendingInterstitial} from '#/components/interstitials/Trending'
import {LiveEventFeedCardWide} from '#/features/liveEvents/components/LiveEventFeedCardWide'
import {
LiveEventFeedOptionsMenu,
useDialogControl,
} from '#/features/liveEvents/components/LiveEventFeedOptionsMenu'
import {useUserPreferencedLiveEvents} from '#/features/liveEvents/context'
import {type LiveEventFeed} from '#/features/liveEvents/types'
export function DiscoverFeedLiveEventFeedsAndTrendingBanner() {
const events = useUserPreferencedLiveEvents()
const {rightNavVisible} = useLayoutBreakpoints()
const {trendingDisabled} = useTrendingSettings()
if (!events.feeds.length) {
if (!rightNavVisible && !trendingDisabled) {
// only show trending on mobile when live event banner is not shown
return <TrendingInterstitial />
} else {
// no feed, no trending
return null
}
}
// On desktop, we show in the sidebar
if (rightNavVisible) return null
return events.feeds.map(feed => <Inner feed={feed} key={feed.id} />)
}
function Inner({feed}: {feed: LiveEventFeed}) {
const {_} = useLingui()
const optionsMenuControl = useDialogControl()
const layout = feed.layouts.wide
return (
<>
<View style={[a.px_lg, a.pt_md, a.pb_xs]}>
<View>
<LiveEventFeedCardWide feed={feed} metricContext="discover" />
<Button
label={_(msg`Configure live event banner`)}
size="tiny"
shape="round"
style={[a.absolute, a.z_10, {top: 6, right: 6}]}
onPress={() => {
optionsMenuControl.open()
}}>
{({hovered, pressed}) => (
<>
<View
style={[
a.absolute,
a.inset_0,
a.rounded_full,
{
backgroundColor: layout.overlayColor,
opacity: hovered || pressed ? 0.8 : 0.6,
},
]}
/>
<EllipsisIcon
size="sm"
fill={layout.textColor}
style={[a.z_20]}
/>
</>
)}
</Button>
</View>
</View>
<LiveEventFeedOptionsMenu
feed={feed}
control={optionsMenuControl}
metricContext="discover"
/>
</>
)
}
@@ -0,0 +1,17 @@
import {View} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
import {LiveEventFeedCardWide} from '#/features/liveEvents/components/LiveEventFeedCardWide'
import {useLiveEvents} from '#/features/liveEvents/context'
export function ExploreScreenLiveEventFeedsBanner() {
const t = useTheme()
const events = useLiveEvents()
return events.feeds.map(feed => (
<View
key={feed.id}
style={[a.p_lg, a.border_b, t.atoms.border_contrast_low]}>
<LiveEventFeedCardWide feed={feed} metricContext="explore" />
</View>
))
}
@@ -0,0 +1,132 @@
import {useEffect, useMemo} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {atoms as a, utils} from '#/alf'
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
import {
type LiveEventFeed,
type LiveEventFeedMetricContext,
} from '#/features/liveEvents/types'
const roundedStyles = [a.rounded_md, a.curve_continuous]
export function LiveEventFeedCardCompact({
feed,
metricContext,
}: {
feed: LiveEventFeed
metricContext: LiveEventFeedMetricContext
}) {
const {_} = useLingui()
const layout = feed.layouts.compact
const overlayColor = layout.overlayColor
const textColor = layout.textColor
const url = useMemo(() => {
// Validated in multiple places on the backend
if (isBskyCustomFeedUrl(feed.url)) {
return new URL(feed.url).pathname
}
return '/'
}, [feed.url])
useEffect(() => {
logger.metric('liveEvents:feedBanner:seen', {
feed: feed.url,
context: metricContext,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<Link
to={url}
label={_(msg`Live event happening now: ${feed.title}`)}
style={[a.w_full]}
onPress={() => {
logger.metric('liveEvents:feedBanner:click', {
feed: feed.url,
context: metricContext,
})
}}>
{({hovered, pressed}) => (
<View style={[roundedStyles, a.shadow_md, a.w_full]}>
<View
style={[a.w_full, a.align_start, a.overflow_hidden, roundedStyles]}>
<Image
accessibilityIgnoresInvertColors
source={{uri: layout.image}}
placeholder={{blurhash: layout.blurhash}}
style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
contentFit="cover"
placeholderContentFit="cover"
/>
<LinearGradient
colors={[overlayColor, utils.alpha(overlayColor, 0)]}
locations={[0, 1]}
start={{x: 0, y: 0}}
end={{x: 1, y: 0}}
style={[
a.absolute,
a.inset_0,
a.transition_opacity,
{
transitionDuration: '200ms',
opacity: hovered || pressed ? 0.6 : 0,
},
]}
/>
<View style={[a.w_full, a.justify_end]}>
<LinearGradient
colors={[
overlayColor,
utils.alpha(overlayColor, 0.7),
utils.alpha(overlayColor, 0),
]}
locations={[0, 0.8, 1]}
start={{x: 0, y: 0}}
end={{x: 1, y: 0}}
style={[a.absolute, a.inset_0]}
/>
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.gap_xs,
a.z_10,
a.px_lg,
a.py_md,
]}>
<LiveIcon size="md" fill={textColor} />
<Text
numberOfLines={1}
style={[
a.flex_1,
a.leading_snug,
a.font_bold,
a.text_lg,
a.pr_xl,
{color: textColor},
]}>
{layout.title}
</Text>
</View>
</View>
</View>
</View>
)}
</Link>
)
}
@@ -0,0 +1,139 @@
import {useEffect, useMemo} 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 {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {atoms as a, useBreakpoints, utils} from '#/alf'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
import {
type LiveEventFeed,
type LiveEventFeedMetricContext,
} from '#/features/liveEvents/types'
const roundedStyles = [a.rounded_lg, a.curve_continuous]
export function LiveEventFeedCardWide({
feed,
metricContext,
}: {
feed: LiveEventFeed
metricContext: LiveEventFeedMetricContext
}) {
const {_} = useLingui()
const {gtPhone} = useBreakpoints()
const layout = feed.layouts.wide
const overlayColor = layout.overlayColor
const textColor = layout.textColor
const url = useMemo(() => {
// Validated in multiple places on the backend
if (isBskyCustomFeedUrl(feed.url)) {
return new URL(feed.url).pathname
}
return '/'
}, [feed.url])
useEffect(() => {
logger.metric('liveEvents:feedBanner:seen', {
feed: feed.url,
context: metricContext,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<Link
to={url}
label={_(msg`Live event happening now: ${feed.title}`)}
style={[a.w_full]}
onPress={() => {
logger.metric('liveEvents:feedBanner:click', {
feed: feed.url,
context: metricContext,
})
}}>
{({hovered, pressed}) => (
<View style={[roundedStyles, a.shadow_md, a.w_full]}>
<View
style={[
a.align_start,
roundedStyles,
a.overflow_hidden,
{
aspectRatio: gtPhone ? 576 / 144 : 369 / 100,
},
]}>
<Image
accessibilityIgnoresInvertColors
source={{uri: layout.image}}
placeholder={{blurhash: layout.blurhash}}
style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
contentFit="cover"
placeholderContentFit="cover"
/>
<LinearGradient
colors={[overlayColor, utils.alpha(overlayColor, 0)]}
locations={[0, 1]}
start={{x: 0, y: 0}}
end={{x: 1, y: 0}}
style={[
a.absolute,
a.inset_0,
a.transition_opacity,
{
transitionDuration: '200ms',
opacity: hovered || pressed ? 0.6 : 0,
},
]}
/>
<View style={[a.flex_1, a.justify_end]}>
<LinearGradient
colors={[overlayColor, utils.alpha(overlayColor, 0)]}
locations={[0, 1]}
start={{x: 0, y: 0}}
end={{x: 1, y: 0}}
style={[a.absolute, a.inset_0]}
/>
<View
style={[
a.z_10,
gtPhone ? [a.pl_xl, a.pb_lg] : [a.pl_lg, a.pb_md],
{paddingRight: 64},
]}>
<Text
style={[
a.leading_snug,
gtPhone ? a.text_xs : a.text_2xs,
{color: textColor, opacity: 0.8},
]}>
{feed.preview ? (
<Trans>Preview</Trans>
) : (
<Trans>Happening now</Trans>
)}
</Text>
<Text
style={[
a.leading_snug,
a.font_bold,
gtPhone ? a.text_3xl : a.text_lg,
{color: textColor},
]}>
{layout.title}
</Text>
</View>
</View>
</View>
</View>
)}
</Link>
)
}
@@ -0,0 +1,171 @@
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useCleanError} from '#/lib/hooks/useCleanError'
import {isNative} from '#/platform/detection'
import {atoms as a, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Loader} from '#/components/Loader'
import * as toast from '#/components/Toast'
import {Span, Text} from '#/components/Typography'
import {useUpdateLiveEventPreferences} from '#/features/liveEvents/preferences'
import {
type LiveEventFeed,
type LiveEventFeedMetricContext,
} from '#/features/liveEvents/types'
export {useDialogControl} from '#/components/Dialog'
export function LiveEventFeedOptionsMenu({
control,
feed,
metricContext,
}: {
control: Dialog.DialogControlProps
feed: LiveEventFeed
metricContext: LiveEventFeedMetricContext
}) {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Configure live event banner`)}
style={[web({maxWidth: 400})]}>
<Inner control={control} feed={feed} metricContext={metricContext} />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function Inner({
control,
feed,
metricContext,
}: {
control: Dialog.DialogControlProps
feed: LiveEventFeed
metricContext: LiveEventFeedMetricContext
}) {
const {_} = useLingui()
const {
isPending,
mutate: update,
error: rawError,
variables,
} = useUpdateLiveEventPreferences({
feed,
metricContext,
onUpdateSuccess({undoAction}) {
toast.show(
<toast.Outer>
<toast.Icon />
<toast.Text>
<Trans>Your live event preferences have been updated.</Trans>
</toast.Text>
{undoAction && (
<toast.Action
label={_(msg`Undo`)}
onPress={() => {
if (undoAction) {
update(undoAction)
}
}}>
<Trans>Undo</Trans>
</toast.Action>
)}
</toast.Outer>,
{
type: 'success',
},
)
/*
* If there is no `undoAction`, it means that the action was already
* undone, and therefore the menu would have been closed prior to the
* undo happening.
*/
if (undoAction) {
control.close()
}
},
})
const cleanError = useCleanError()
const error = rawError ? cleanError(rawError) : undefined
const isHidingFeed = variables?.type === 'hideFeed' && isPending
const isHidingAllFeeds = variables?.type === 'toggleHideAllFeeds' && isPending
return (
<View style={[a.gap_lg]}>
<View style={[a.gap_sm]}>
<Text style={[a.text_2xl, a.font_semi_bold, a.leading_snug]}>
<Trans>Live event options</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Live events appear occasionally when something exciting is
happening. If you'd like, you can hide this particular event, or all
events for this placement in your app interface.
</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
If you choose to hide all events, you can always re-enable them from{' '}
<Span style={[a.font_semi_bold]}>Settings → Content & Media</Span>.
</Trans>
</Text>
</View>
<View style={[a.gap_sm]}>
<Button
label={_(msg`Hide this event`)}
size="large"
color="primary_subtle"
onPress={() => {
update({type: 'hideFeed', id: feed.id})
}}>
<ButtonText>
<Trans>Hide this event</Trans>
</ButtonText>
{isHidingFeed && <ButtonIcon icon={Loader} />}
</Button>
<Button
label={_(msg`Hide all events`)}
size="large"
color="secondary"
onPress={() => {
update({type: 'toggleHideAllFeeds'})
}}>
<ButtonText>
<Trans>Hide all events</Trans>
</ButtonText>
{isHidingAllFeeds && <ButtonIcon icon={Loader} />}
</Button>
{isNative && (
<Button
label={_(msg`Cancel`)}
size="large"
color="secondary_inverted"
onPress={() => control.close()}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
)}
</View>
{error && (
<Admonition type="error">
{error.clean || error.raw || _(msg`An unknown error occurred.`)}
</Admonition>
)}
</View>
)
}
@@ -0,0 +1,43 @@
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
import * as Toggle from '#/components/forms/Toggle'
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
import {
useLiveEventPreferences,
useUpdateLiveEventPreferences,
} from '#/features/liveEvents/preferences'
export function LiveEventFeedsSettingsToggle() {
const {_} = useLingui()
const {data: prefs} = useLiveEventPreferences()
const {
isPending,
data: updatedPrefs,
mutate: update,
} = useUpdateLiveEventPreferences({
metricContext: 'settings',
})
const hideAllFeeds = !!(updatedPrefs || prefs)?.hideAllFeeds
return (
<Toggle.Item
name="enable_live_event_banner"
label={_(msg`Show live events in your Discover Feed`)}
value={!hideAllFeeds}
onChange={() => {
if (!isPending) {
update({type: 'toggleHideAllFeeds'})
}
}}>
<SettingsList.Item>
<SettingsList.ItemIcon icon={LiveIcon} />
<SettingsList.ItemText>
<Trans>Show live events in your Discover Feed</Trans>
</SettingsList.ItemText>
<Toggle.Platform />
</SettingsList.Item>
</Toggle.Item>
)
}
@@ -0,0 +1,13 @@
import {LiveEventFeedCardCompact} from '#/features/liveEvents/components/LiveEventFeedCardCompact'
import {useLiveEvents} from '#/features/liveEvents/context'
export function SidebarLiveEventFeedsBanner() {
const events = useLiveEvents()
return events.feeds.map(feed => (
<LiveEventFeedCardCompact
key={feed.id}
feed={feed}
metricContext="sidebar"
/>
))
}
+110
View File
@@ -0,0 +1,110 @@
import {createContext, useContext, useMemo} from 'react'
import {QueryClient, useQuery} from '@tanstack/react-query'
import {useIsBskyTeam} from '#/lib/hooks/useIsBskyTeam'
import {
convertBskyAppUrlIfNeeded,
isBskyCustomFeedUrl,
makeRecordUri,
} from '#/lib/strings/url-helpers'
import {IS_DEV, 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'
const qc = new QueryClient()
const liveEventsQueryKey = ['live-events']
export const DEFAULT_LIVE_EVENTS = {
feeds: [],
}
async function fetchLiveEvents(): Promise<LiveEventsWorkerResponse | null> {
try {
const res = await fetch(`${LIVE_EVENTS_URL}/config`)
if (!res.ok) return null
const data = await res.json()
return data
} catch {
return null
}
}
const Context = createContext<LiveEventsWorkerResponse>(DEFAULT_LIVE_EVENTS)
export function Provider({children}: React.PropsWithChildren<{}>) {
const [isDevMode] = useDevMode()
const isBskyTeam = useIsBskyTeam()
const {data} = useQuery(
{
staleTime: IS_DEV ? 5e3 : 1000 * 60,
queryKey: liveEventsQueryKey,
async queryFn() {
return fetchLiveEvents()
},
},
qc,
)
const ctx = useMemo(() => {
if (!data) return DEFAULT_LIVE_EVENTS
const feeds = data.feeds.filter(f => {
if (f.preview && !isBskyTeam) return false
return true
})
return {
...data,
// only one at a time for now, unless bsky team and dev mode
feeds: isBskyTeam && isDevMode ? feeds : feeds.slice(0, 1),
}
}, [data, isBskyTeam, isDevMode])
return <Context.Provider value={ctx}>{children}</Context.Provider>
}
export async function prefetchLiveEvents() {
const data = await fetchLiveEvents()
if (data) {
qc.setQueryData(liveEventsQueryKey, data)
}
}
export function useLiveEvents() {
const ctx = useContext(Context)
if (!ctx) {
throw new Error('useLiveEventsContext must be used within a Provider')
}
return ctx
}
export function useUserPreferencedLiveEvents() {
const events = useLiveEvents()
const {data, isLoading} = useLiveEventPreferences()
if (isLoading) return DEFAULT_LIVE_EVENTS
const {hideAllFeeds, hiddenFeedIds} = data
return {
...events,
feeds: hideAllFeeds
? []
: events.feeds.filter(f => {
const hidden = f?.id ? hiddenFeedIds.includes(f?.id || '') : false
return !hidden
}),
}
}
export function useActiveLiveEventFeedUris() {
const {feeds} = useLiveEvents()
return new Set(
feeds
// insurance
.filter(f => isBskyCustomFeedUrl(f.url))
.map(f => {
const uri = convertBskyAppUrlIfNeeded(f.url)
const [_0, did, _1, rkey] = uri.split('/').filter(Boolean)
const urip = makeRecordUri(did, 'app.bsky.feed.generator', rkey)
return urip.toString()
}),
)
}
+161
View File
@@ -0,0 +1,161 @@
import {useEffect} from 'react'
import {type Agent, AppBskyActorDefs, asPredicate} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {
preferencesQueryKey,
usePreferencesQuery,
} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
import * as env from '#/env'
import {
type LiveEventFeed,
type LiveEventFeedMetricContext,
} from '#/features/liveEvents/types'
export type LiveEventPreferencesAction = Parameters<
Agent['updateLiveEventPreferences']
>[0] & {
/**
* Flag that is internal to this hook, do not set when updating prefs
*/
__canUndo?: boolean
}
export function useLiveEventPreferences() {
const query = usePreferencesQuery()
useWebOnlyDebugLiveEventPreferences()
return {
...query,
data: query.data?.liveEventPreferences || {
hideAllFeeds: false,
hiddenFeedIds: [],
},
}
}
function useWebOnlyDebugLiveEventPreferences() {
const queryClient = useQueryClient()
const agent = useAgent()
useEffect(() => {
if (env.IS_DEV && isWeb && typeof window !== 'undefined') {
// @ts-ignore
window.__updateLiveEventPreferences = async (
action: LiveEventPreferencesAction,
) => {
await agent.updateLiveEventPreferences(action)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
}
}
}, [agent, queryClient])
}
export function useUpdateLiveEventPreferences(props: {
feed?: LiveEventFeed
metricContext: LiveEventFeedMetricContext
onUpdateSuccess?: (props: {
undoAction: LiveEventPreferencesAction | null
}) => void
}) {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation<
AppBskyActorDefs.LiveEventPreferences,
Error,
LiveEventPreferencesAction,
{undoAction: LiveEventPreferencesAction | null}
>({
onSettled(data, error, variables) {
/*
* `onSettled` runs after the mutation completes, success or no. The idea
* here is that we want to invert the action that was just passed in, and
* provide it as an `undoAction` to the `onUpdateSuccess` callback.
*
* If the operation was not a success, we don't provide the `undoAction`.
*
* Upon the first call of the mutation, the `__canUndo` flag is undefined,
* so we allow the undo. However, when we create the `undoAction`, we
* set its `__canUndo` flag to false, so that if the user were to call
* the undo action, we would not provide another undo for that.
*/
const canUndo = variables.__canUndo === undefined ? true : false
let undoAction: LiveEventPreferencesAction | null = null
switch (variables.type) {
case 'hideFeed':
undoAction = {type: 'unhideFeed', id: variables.id, __canUndo: false}
break
case 'unhideFeed':
undoAction = {type: 'hideFeed', id: variables.id, __canUndo: false}
break
case 'toggleHideAllFeeds':
undoAction = {type: 'toggleHideAllFeeds', __canUndo: false}
break
}
if (data && !error) {
props?.onUpdateSuccess?.({
undoAction: canUndo ? undoAction : null,
})
}
},
mutationFn: async action => {
const updated = await agent.updateLiveEventPreferences(action)
const prefs = updated.find(p =>
asPredicate(AppBskyActorDefs.validateLiveEventPreferences)(p),
)
switch (action.type) {
case 'hideFeed':
case 'unhideFeed': {
if (!props.feed) {
logger.error(
`useUpdateLiveEventPreferences: feed is missing, but required for hiding/unhiding`,
{
action,
},
)
break
}
logger.metric(
action.type === 'hideFeed'
? 'liveEvents:feedBanner:hide'
: 'liveEvents:feedBanner:unhide',
{
feed: props.feed.url,
context: props.metricContext,
},
)
break
}
case 'toggleHideAllFeeds': {
if (prefs!.hideAllFeeds) {
logger.metric('liveEvents:hideAllFeedBanners', {
context: props.metricContext,
})
} else {
logger.metric('liveEvents:unhideAllFeedBanners', {
context: props.metricContext,
})
}
break
}
}
// triggers a refetch
queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
return prefs!
},
})
}
+27
View File
@@ -0,0 +1,27 @@
export type LiveEventFeedImageLayout = 'wide' | 'compact' // maybe more in the future
export type LiveEventFeedLayout = {
title: string
overlayColor: string
textColor: string
image: string
blurhash: string
}
export type LiveEventFeed = {
id: string
preview: boolean
title: string
url: string
layouts: Record<LiveEventFeedImageLayout, LiveEventFeedLayout>
}
export type LiveEventsWorkerResponse = {
feeds: LiveEventFeed[]
}
export type LiveEventFeedMetricContext =
| 'explore'
| 'discover'
| 'sidebar'
| 'settings'
+31
View File
@@ -0,0 +1,31 @@
import {View, type ViewStyle} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
/**
* The little blue dot used to nudge a user towards a certain feature. The dot
* is absolutely positioned, and is intended to be configured by passing in
* positional styles via `top`, `bottom`, `left`, and `right` props.
*/
export function Dot({
top,
bottom,
left,
right,
}: Pick<ViewStyle, 'top' | 'bottom' | 'left' | 'right'>) {
const t = useTheme()
return (
<View style={[a.absolute, {top, bottom, left, right}]}>
<View
style={[
a.rounded_full,
{
height: 8,
width: 8,
backgroundColor: t.palette.primary_500,
},
]}
/>
</View>
)
}
+24
View File
@@ -0,0 +1,24 @@
import {LinearGradient} from 'expo-linear-gradient'
import {atoms as a, useTheme, utils, type ViewStyleProp} from '#/alf'
/**
* A gradient overlay using the primary color at low opacity. This component is
* absolutely positioned and intended to be composed within other components,
* with optional styling allowed, such as adjusting border radius.
*/
export function Gradient({style}: ViewStyleProp) {
const t = useTheme()
return (
<LinearGradient
colors={[
utils.alpha(t.palette.primary_500, 0.2),
utils.alpha(t.palette.primary_500, 0.1),
]}
locations={[0, 1]}
start={{x: 0, y: 0}}
end={{x: 1, y: 0}}
style={[a.absolute, a.inset_0, style]}
/>
)
}
+2 -2
View File
@@ -1,7 +1,7 @@
import {BAPP_CONFIG_URL} from '#/env'
import {GEOLOCATION_URL} from '#/env'
import {type Geolocation} from '#/geolocation/types'
export const GEOLOCATION_SERVICE_URL = `${BAPP_CONFIG_URL}/geolocation`
export const GEOLOCATION_SERVICE_URL = `${GEOLOCATION_URL}/geolocation`
/**
* Default geolocation config.
+13
View File
@@ -1,4 +1,5 @@
import {useCallback, useEffect, useRef} from 'react'
import {Platform} from 'react-native'
import * as Location from 'expo-location'
import {createPermissionHook} from 'expo-modules-core'
@@ -57,6 +58,18 @@ export async function getDeviceGeolocation(): Promise<Geolocation> {
})
const location = locations.at(0)
const normalized = location ? normalizeDeviceLocation(location) : undefined
if (normalized?.regionCode && normalized.regionCode.length > 5) {
/*
* We want short codes only, and we're still seeing some full names here.
* 5 is just a heuristic for a region that is probably not formatted as a
* short code.
*/
logger.error('getDeviceGeolocation: invalid regionCode', {
os: Platform.OS,
version: Platform.Version,
regionCode: normalized.regionCode,
})
}
return {
countryCode: normalized?.countryCode ?? undefined,
regionCode: normalized?.regionCode ?? undefined,
+4 -9
View File
@@ -7,7 +7,7 @@ import {
import {isAfter, parseISO} from 'date-fns'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {useLiveNowConfig} from '#/state/service-config'
import {type LiveNowConfig, useLiveNowConfig} from '#/state/service-config'
import {useTickEveryMinute} from '#/state/shell'
import type * as bsky from '#/types/bsky'
@@ -20,7 +20,7 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
tick! // revalidate every minute
if (shadowed && 'status' in shadowed && shadowed.status) {
const isValid = validateStatus(shadowed.did, 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,19 +65,14 @@ export function isStatusStillActive(timeStr: string | undefined) {
}
export function validateStatus(
did: string,
status: AppBskyActorDefs.StatusView,
config: {did: string; domains: string[]}[],
config: LiveNowConfig,
) {
if (status.status !== 'app.bsky.actor.status#live') return false
const sources = config.find(cfg => cfg.did === did)
if (!sources) {
return false
}
try {
if (AppBskyEmbedExternal.isView(status.embed)) {
const url = new URL(status.embed.external.uri)
return sources.domains.includes(url.hostname)
return config.allowedDomains.has(url.hostname)
} else {
return false
}
+8
View File
@@ -0,0 +1,8 @@
import {useMemo} from 'react'
import {useGate} from '#/lib/statsig/statsig'
export function useIsBskyTeam() {
const gate = useGate()
return useMemo(() => gate('is_bsky_team_member'), [gate])
}
+13 -2
View File
@@ -1,4 +1,4 @@
import {useRef, useState} from 'react'
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'
@@ -9,9 +9,15 @@ import {
} from '@tanstack/react-query-persist-client'
import type React from 'react'
import {isNative} from '#/platform/detection'
import {isNative, isWeb} from '#/platform/detection'
import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events'
declare global {
interface Window {
__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]
@@ -180,6 +186,11 @@ function QueryProviderInner({
dehydrateOptions,
}
})
useEffect(() => {
if (isWeb) {
window.__TANSTACK_QUERY_CLIENT__ = queryClient
}
}, [queryClient])
return (
<PersistQueryClientProvider
client={queryClient}
+2
View File
@@ -7,6 +7,8 @@ export type Gate =
| 'disable_settings_find_contacts'
| 'explore_show_suggested_feeds'
| 'feed_reply_button_open_thread'
| 'is_bsky_team_member' // special, do not remove
| 'live_now_beta'
| 'old_postonboarding'
| 'onboarding_add_video_feed'
| 'onboarding_suggested_starterpacks'
File diff suppressed because it is too large Load Diff
+2
View File
@@ -16,6 +16,8 @@ import {enabledLogLevels} from '#/logger/util'
import {isNative} from '#/platform/detection'
import {ENV} from '#/env'
export {type MetricEvents as Metrics} from '#/logger/metrics'
const TRANSPORTS: Transport[] = (function configureTransports() {
switch (ENV) {
case 'production': {
+32
View File
@@ -1,5 +1,6 @@
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
export type MetricEvents = {
// App events
@@ -244,6 +245,8 @@ export type MetricEvents = {
isReply: boolean
}
'post:like': {
uri: string
authorDid: string
doesLikerFollowPoster: boolean | undefined
doesPosterFollowLiker: boolean | undefined
likerClout: number | undefined
@@ -252,14 +255,20 @@ export type MetricEvents = {
feedDescriptor?: string
}
'post:repost': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
}
'post:unlike': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
}
'post:unrepost': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
}
@@ -788,4 +797,27 @@ export type MetricEvents = {
}
// user pressed the remove all data button
'contacts:settings:removeData': {}
'liveEvents:feedBanner:seen': {
feed: string
context: LiveEventFeedMetricContext
}
'liveEvents:feedBanner:click': {
feed: string
context: LiveEventFeedMetricContext
}
'liveEvents:feedBanner:hide': {
feed: string
context: LiveEventFeedMetricContext
}
'liveEvents:feedBanner:unhide': {
feed: string
context: LiveEventFeedMetricContext
}
'liveEvents:hideAllFeedBanners': {
context: LiveEventFeedMetricContext
}
'liveEvents:unhideAllFeedBanners': {
context: LiveEventFeedMetricContext
}
}
+21 -8
View File
@@ -46,13 +46,22 @@ export default function HashtagScreen({
const {tag, author} = route.params
const {_} = useLingui()
const fullTag = React.useMemo(() => {
return `#${decodeURIComponent(tag)}`
const decodedTag = React.useMemo(() => {
return decodeURIComponent(tag)
}, [tag])
const isCashtag = decodedTag.startsWith('$')
const fullTag = React.useMemo(() => {
// Cashtags already include the $ prefix, hashtags need # added
return isCashtag ? decodedTag : `#${decodedTag}`
}, [decodedTag, isCashtag])
const headerTitle = React.useMemo(() => {
return enforceLen(fullTag.toLowerCase(), 24, true, 'middle')
}, [fullTag])
// Keep cashtags uppercase, lowercase hashtags
const displayTag = isCashtag ? fullTag.toUpperCase() : fullTag.toLowerCase()
return enforceLen(displayTag, 24, true, 'middle')
}, [fullTag, isCashtag])
const sanitizedAuthor = React.useMemo(() => {
if (!author) return
@@ -172,10 +181,14 @@ function HashtagScreenTab({
const {hasSession} = useSession()
const trackPostView = usePostViewTracking('Hashtag')
const isCashtag = fullTag.startsWith('$')
const queryParam = React.useMemo(() => {
if (!author) return fullTag
return `${fullTag} from:${author}`
}, [fullTag, author])
// Cashtags need # prefix for search: "#$BTC" or "#$BTC from:author"
const searchTag = isCashtag ? `#${fullTag}` : fullTag
if (!author) return searchTag
return `${searchTag} from:${author}`
}, [fullTag, author, isCashtag])
const {
data,
@@ -255,7 +268,7 @@ function HashtagScreenTab({
isError={isError}
onRetry={refetch}
emptyType="results"
emptyMessage={_(msg`We couldn't find any results for that hashtag.`)}
emptyMessage={_(msg`We couldn't find any results for that tag.`)}
/>
) : (
<List
+10
View File
@@ -68,6 +68,7 @@ import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
import {ExploreScreenLiveEventFeedsBanner} from '#/features/liveEvents/components/ExploreScreenLiveEventFeedsBanner'
import * as ModuleHeader from './components/ModuleHeader'
import {
SuggestedAccountsTabBar,
@@ -201,6 +202,10 @@ type ExploreScreenItems =
type: 'interests-card'
key: 'interests-card'
}
| {
type: 'liveEventFeedsBanner'
key: string
}
export function Explore({
focusSearchInput,
@@ -684,6 +689,8 @@ export function Explore({
i.push(topBorder)
i.push(...interestsNuxModule)
i.push({type: 'liveEventFeedsBanner', key: 'liveEventFeedsBanner'})
if (useFullExperience) {
i.push(trendingTopicsModule)
i.push(...suggestedFeedsModule)
@@ -998,6 +1005,9 @@ export function Explore({
case 'interests-card': {
return <ExploreInterestsCard />
}
case 'liveEventFeedsBanner': {
return <ExploreScreenLiveEventFeedsBanner />
}
}
},
[
@@ -26,6 +26,7 @@ import {Play_Stroke2_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending'
import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window'
import * as Layout from '#/components/Layout'
import {LiveEventFeedsSettingsToggle} from '#/features/liveEvents/components/LiveEventFeedsSettingsToggle'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
@@ -124,7 +125,7 @@ export function ContentAndMediaSettingsScreen({}: Props) {
<Toggle.Platform />
</SettingsList.Item>
</Toggle.Item>
{trendingEnabled && (
{trendingEnabled ? (
<>
<SettingsList.Divider />
<Toggle.Item
@@ -148,6 +149,7 @@ export function ContentAndMediaSettingsScreen({}: Props) {
<Toggle.Platform />
</SettingsList.Item>
</Toggle.Item>
<LiveEventFeedsSettingsToggle />
<Toggle.Item
name="show_trending_videos"
label={_(msg`Enable trending videos in your Discover feed`)}
@@ -170,6 +172,11 @@ export function ContentAndMediaSettingsScreen({}: Props) {
</SettingsList.Item>
</Toggle.Item>
</>
) : (
<>
<SettingsList.Divider />
<LiveEventFeedsSettingsToggle />
</>
)}
</SettingsList.Container>
</Layout.Content>
+12
View File
@@ -12,6 +12,8 @@ export enum Nux {
BookmarksAnnouncement = 'BookmarksAnnouncement',
FindContactsAnnouncement = 'FindContactsAnnouncement',
FindContactsDismissibleBanner = 'FindContactsDismissibleBanner',
LiveNowBetaDialog = 'LiveNowBetaDialog',
LiveNowBetaNudge = 'LiveNowBetaNudge',
/*
* Blocking announcements. New IDs are required for each new announcement.
@@ -62,6 +64,14 @@ export type AppNux = BaseNux<
id: Nux.FindContactsDismissibleBanner
data: undefined
}
| {
id: Nux.LiveNowBetaDialog
data: undefined
}
| {
id: Nux.LiveNowBetaNudge
data: undefined
}
>
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
@@ -75,4 +85,6 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
[Nux.BookmarksAnnouncement]: undefined,
[Nux.FindContactsAnnouncement]: undefined,
[Nux.FindContactsDismissibleBanner]: undefined,
[Nux.LiveNowBetaDialog]: undefined,
[Nux.LiveNowBetaNudge]: undefined,
}
+32 -8
View File
@@ -111,7 +111,7 @@ export function usePostLikeMutationQueue(
const postCid = post.cid
const initialLikeUri = post.viewer?.like
const likeMutation = usePostLikeMutation(feedDescriptor, logContext, post)
const unlikeMutation = usePostUnlikeMutation(feedDescriptor, logContext)
const unlikeMutation = usePostUnlikeMutation(feedDescriptor, logContext, post)
const queueToggle = useToggleMutationQueue({
initialState: initialLikeUri,
@@ -182,6 +182,8 @@ function usePostLikeMutation(
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
}
logger.metric('post:like', {
uri,
authorDid: postAuthor.did,
logContext,
doesPosterFollowLiker: postAuthor.viewer
? Boolean(postAuthor.viewer.followedBy)
@@ -206,11 +208,17 @@ function usePostLikeMutation(
function usePostUnlikeMutation(
feedDescriptor: string | undefined,
logContext: LogEvents['post:unlike']['logContext'],
post: Shadow<AppBskyFeedDefs.PostView>,
) {
const agent = useAgent()
return useMutation<void, Error, {postUri: string; likeUri: string}>({
mutationFn: ({likeUri}) => {
logger.metric('post:unlike', {logContext, feedDescriptor})
mutationFn: ({postUri, likeUri}) => {
logger.metric('post:unlike', {
uri: postUri,
authorDid: post.author.did,
logContext,
feedDescriptor,
})
return agent.deleteLike(likeUri)
},
})
@@ -227,8 +235,12 @@ export function usePostRepostMutationQueue(
const postUri = post.uri
const postCid = post.cid
const initialRepostUri = post.viewer?.repost
const repostMutation = usePostRepostMutation(feedDescriptor, logContext)
const unrepostMutation = usePostUnrepostMutation(feedDescriptor, logContext)
const repostMutation = usePostRepostMutation(feedDescriptor, logContext, post)
const unrepostMutation = usePostUnrepostMutation(
feedDescriptor,
logContext,
post,
)
const queueToggle = useToggleMutationQueue({
initialState: initialRepostUri,
@@ -280,6 +292,7 @@ export function usePostRepostMutationQueue(
function usePostRepostMutation(
feedDescriptor: string | undefined,
logContext: LogEvents['post:repost']['logContext'],
post: Shadow<AppBskyFeedDefs.PostView>,
) {
const agent = useAgent()
return useMutation<
@@ -288,7 +301,12 @@ function usePostRepostMutation(
{uri: string; cid: string; via?: {uri: string; cid: string}} // the post's uri and cid, and the repost uri/cid if present
>({
mutationFn: ({uri, cid, via}) => {
logger.metric('post:repost', {logContext, feedDescriptor})
logger.metric('post:repost', {
uri,
authorDid: post.author.did,
logContext,
feedDescriptor,
})
return agent.repost(uri, cid, via)
},
})
@@ -297,11 +315,17 @@ function usePostRepostMutation(
function usePostUnrepostMutation(
feedDescriptor: string | undefined,
logContext: LogEvents['post:unrepost']['logContext'],
post: Shadow<AppBskyFeedDefs.PostView>,
) {
const agent = useAgent()
return useMutation<void, Error, {postUri: string; repostUri: string}>({
mutationFn: ({repostUri}) => {
logger.metric('post:unrepost', {logContext, feedDescriptor})
mutationFn: ({postUri, repostUri}) => {
logger.metric('post:unrepost', {
uri: postUri,
authorDid: post.author.did,
logContext,
feedDescriptor,
})
return agent.deleteRepost(repostUri)
},
})
+4
View File
@@ -45,4 +45,8 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
verificationPrefs: {
hideBadges: false,
},
liveEventPreferences: {
hideAllFeeds: false,
hiddenFeedIds: [],
},
}
+22 -10
View File
@@ -1,7 +1,10 @@
import {createContext, useContext, useMemo} from 'react'
import {useGate} from '#/lib/statsig/statsig'
import {useLanguagePrefs} from '#/state/preferences/languages'
import {useServiceConfigQuery} from '#/state/queries/service-config'
import {useSession} from '#/state/session'
import {IS_DEV} from '#/env'
import {device} from '#/storage'
type TrendingContext = {
@@ -18,7 +21,7 @@ const TrendingContext = createContext<TrendingContext>({
})
TrendingContext.displayName = 'TrendingContext'
const LiveNowContext = createContext<LiveNowContext | null>(null)
const LiveNowContext = createContext<LiveNowContext>([])
LiveNowContext.displayName = 'LiveNowContext'
const CheckEmailConfirmedContext = createContext<boolean | null>(null)
@@ -82,19 +85,28 @@ export function useTrendingConfig() {
return useContext(TrendingContext)
}
export function useLiveNowConfig() {
const DEFAULT_LIVE_ALLOWED_DOMAINS = ['twitch.tv', 'www.twitch.tv']
export type LiveNowConfig = {
allowedDomains: Set<string>
}
export function useLiveNowConfig(): LiveNowConfig {
const ctx = useContext(LiveNowContext)
if (!ctx) {
throw new Error(
'useLiveNowConfig must be used within a ServiceConfigManager',
)
const canGoLive = useCanGoLive()
const {currentAccount} = useSession()
if (!currentAccount?.did || !canGoLive) return {allowedDomains: new Set()}
const vip = ctx.find(live => live.did === currentAccount.did)
return {
allowedDomains: new Set(
DEFAULT_LIVE_ALLOWED_DOMAINS.concat(vip ? vip.domains : []),
),
}
return ctx
}
export function useCanGoLive(did?: string) {
const config = useLiveNowConfig()
return !!config.find(cfg => cfg.did === did)
export function useCanGoLive() {
const gate = useGate()
const {hasSession} = useSession()
if (!hasSession) return false
return IS_DEV ? true : gate('live_now_beta')
}
export function useCheckEmailConfirmed() {
@@ -14,7 +14,11 @@
* the facet-set.
*/
import {TAG_REGEX, TRAILING_PUNCTUATION_REGEX} from '@atproto/api'
import {
CASHTAG_REGEX,
TAG_REGEX,
TRAILING_PUNCTUATION_REGEX,
} from '@atproto/api'
import {Mark} from '@tiptap/core'
import {type Node as ProsemirrorNode} from '@tiptap/pm/model'
import {Plugin, PluginKey} from '@tiptap/pm/state'
@@ -28,6 +32,7 @@ function getDecorations(doc: ProsemirrorNode) {
const regex = TAG_REGEX
const textContent = node.textContent
// Detect hashtags
let match
while ((match = regex.exec(textContent))) {
const [matchedString, __, tag] = match
@@ -52,6 +57,27 @@ function getDecorations(doc: ProsemirrorNode) {
}),
)
}
// Detect cashtags
const cashtagRegex = new RegExp(CASHTAG_REGEX.source, 'gu')
while ((match = cashtagRegex.exec(textContent))) {
const [_fullMatch, leading, ticker] = match
if (!ticker) continue
// Calculate positions: leading char + $ + ticker
const matchedFrom = match.index + leading.length
const matchedTo = matchedFrom + 1 + ticker.length // +1 for $
const start = pos + matchedFrom
const end = pos + matchedTo
decorations.push(
Decoration.inline(start, end, {
class: 'autolink',
}),
)
}
}
})
-2
View File
@@ -148,8 +148,6 @@ export function ComposerPrompt() {
a.relative,
a.flex_row,
a.align_start,
a.border_t,
t.atoms.border_contrast_low,
{
paddingLeft: 18,
paddingRight: 15,
+13 -11
View File
@@ -70,6 +70,7 @@ import {
} from '#/components/feeds/PostFeedVideoGridRow'
import {TrendingInterstitial} from '#/components/interstitials/Trending'
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner'
import {ComposerPrompt} from '../feeds/ComposerPrompt'
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
import {FeedShutdownMsg} from './FeedShutdownMsg'
@@ -155,6 +156,10 @@ type FeedRow =
type: 'composerPrompt'
key: string
}
| {
type: 'liveEventFeedsAndTrendingBanner'
key: string
}
export function getItemsForFeedback(feedRow: FeedRow): {
item: FeedPostSliceItem
@@ -360,7 +365,7 @@ let PostFeed = ({
const showProgressIntersitial =
(followProgressGuide || followAndLikeProgressGuide) && !rightNavVisible
const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
const {trendingVideoDisabled} = useTrendingSettings()
const ageAssuranceBannerState = useAgeAssuranceBannerState()
const selectedFeed = useSelectedFeed()
@@ -510,13 +515,10 @@ let PostFeed = ({
})
}
}
if (!rightNavVisible && !trendingDisabled) {
arr.push({
type: 'interstitialTrending',
key:
'interstitial2-' + sliceIndex + '-' + lastFetchedAt,
})
}
arr.push({
type: 'liveEventFeedsAndTrendingBanner',
key: 'liveEventFeedsAndTrendingBanner-' + sliceIndex,
})
// Show composer prompt for Discover and Following feeds
if (
hasSession &&
@@ -672,9 +674,7 @@ let PostFeed = ({
feedTab,
hasSession,
showProgressIntersitial,
trendingDisabled,
trendingVideoDisabled,
rightNavVisible,
gtMobile,
isVideoFeed,
areVideoFeedsEnabled,
@@ -773,6 +773,8 @@ let PostFeed = ({
return <AgeAssuranceDismissibleFeedBanner />
} else if (row.type === 'interstitialTrending') {
return <TrendingInterstitial />
} else if (row.type === 'liveEventFeedsAndTrendingBanner') {
return <DiscoverFeedLiveEventFeedsAndTrendingBanner />
} else if (row.type === 'composerPrompt') {
return <ComposerPrompt />
} else if (row.type === 'interstitialTrendingVideos') {
@@ -952,7 +954,7 @@ let PostFeed = ({
const actor = post.author
if (
actor.status &&
validateStatus(actor.did, actor.status, liveNowConfig) &&
validateStatus(actor.status, liveNowConfig) &&
isStatusStillActive(actor.status.expiresAt)
) {
if (!seenActorWithStatusRef.current.has(actor.did)) {
+62 -18
View File
@@ -15,6 +15,7 @@ import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {type Shadow} from '#/state/cache/types'
import {useModalControls} from '#/state/modals'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {
RQKEY as profileQueryKey,
useProfileBlockMutationQueue,
@@ -25,6 +26,7 @@ import {useCanGoLive} from '#/state/service-config'
import {useSession} from '#/state/session'
import {EventStopper} from '#/view/com/util/EventStopper'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog'
@@ -59,6 +61,8 @@ import * as Prompt from '#/components/Prompt'
import {useFullVerificationState} from '#/components/verification'
import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt'
import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt'
import {Dot} from '#/features/nuxs/components/Dot'
import {Gradient} from '#/features/nuxs/components/Gradient'
import {useDevMode} from '#/storage/hooks/dev-mode'
let ProfileMenu = ({
@@ -66,6 +70,7 @@ let ProfileMenu = ({
}: {
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {currentAccount, hasSession} = useSession()
const {openModal} = useModalControls()
@@ -79,8 +84,15 @@ let ProfileMenu = ({
const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked
const [devModeEnabled] = useDevMode()
const verification = useFullVerificationState({profile})
const canGoLive = useCanGoLive(currentAccount?.did)
const canGoLive = useCanGoLive()
const status = useActorStatus(profile)
const statusNudge = useNux(Nux.LiveNowBetaNudge)
const statusNudgeActive =
isSelf &&
canGoLive &&
statusNudge.status === 'ready' &&
!statusNudge.nux?.completed
const {mutate: saveNux} = useSaveNux()
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
@@ -229,17 +241,22 @@ let ProfileMenu = ({
<Menu.Trigger label={_(msg`More options`)}>
{({props}) => {
return (
<Button
{...props}
testID="profileHeaderDropdownBtn"
label={_(msg`More options`)}
hitSlop={HITSLOP_20}
variant="solid"
color="secondary"
size="small"
shape="round">
<ButtonIcon icon={Ellipsis} size="sm" />
</Button>
<>
<Button
{...props}
testID="profileHeaderDropdownBtn"
label={_(msg`More options`)}
hitSlop={HITSLOP_20}
variant="solid"
color="secondary"
size="small"
shape="round">
{statusNudgeActive && <Gradient style={[a.rounded_full]} />}
<ButtonIcon icon={Ellipsis} size="sm" />
</Button>
{statusNudgeActive && <Dot top={1} right={1} />}
</>
)
}}
</Menu.Trigger>
@@ -337,11 +354,19 @@ let ProfileMenu = ({
? _(msg`Edit live status`)
: _(msg`Go live`)
}
onPress={
status.isDisabled
? goLiveDisabledDialogControl.open
: goLiveDialogControl.open
}>
onPress={() => {
if (status.isDisabled) {
goLiveDisabledDialogControl.open()
} else {
goLiveDialogControl.open()
}
saveNux({
id: Nux.LiveNowBetaNudge,
data: undefined,
completed: true,
})
}}>
{statusNudgeActive && <Gradient />}
<Menu.ItemText>
{status.isDisabled ? (
<Trans>Go live (disabled)</Trans>
@@ -351,7 +376,26 @@ let ProfileMenu = ({
<Trans>Go live</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={LiveIcon} />
{statusNudgeActive && (
<Menu.ItemText
style={[
a.flex_0,
{
color: t.palette.primary_500,
right: isWeb ? -8 : -4,
},
]}>
<Trans>New</Trans>
</Menu.ItemText>
)}
<Menu.ItemIcon
icon={LiveIcon}
fill={
statusNudgeActive
? () => t.palette.primary_500
: undefined
}
/>
</Menu.Item>
)}
{verification.viewer.role === 'verifier' &&
+5 -2
View File
@@ -22,6 +22,7 @@ import {CENTER_COLUMN_OFFSET} from '#/components/Layout'
import {InlineLinkText} from '#/components/Link'
import {ProgressGuideList} from '#/components/ProgressGuide/List'
import {Text} from '#/components/Typography'
import {SidebarLiveEventFeedsBanner} from '#/features/liveEvents/components/SidebarLiveEventFeedsBanner'
function useWebQueryParams() {
const navigation = useNavigation()
@@ -49,7 +50,8 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
const isSearchScreen = routeName === 'Search'
const webqueryParams = useWebQueryParams()
const searchQuery = webqueryParams?.q
const showTrending = !isSearchScreen || (isSearchScreen && !!searchQuery)
const showExploreScreenDuplicatedContent =
!isSearchScreen || (isSearchScreen && !!searchQuery)
const {rightNavVisible, centerColumnOffset, leftNavMinimal} =
useLayoutBreakpoints()
@@ -90,7 +92,8 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
</>
)}
{showTrending && <SidebarTrendingTopics />}
{showExploreScreenDuplicatedContent && <SidebarLiveEventFeedsBanner />}
{showExploreScreenDuplicatedContent && <SidebarTrendingTopics />}
<Text style={[a.leading_snug, t.atoms.text_contrast_low]}>
{hasSession && (
+181 -148
View File
@@ -20,16 +20,16 @@
"@jridgewell/gen-mapping" "^0.3.0"
"@jridgewell/trace-mapping" "^0.3.9"
"@atproto-labs/did-resolver@0.2.4":
version "0.2.4"
resolved "https://registry.yarnpkg.com/@atproto-labs/did-resolver/-/did-resolver-0.2.4.tgz#3df8f94845fae10bb284303d6e73ffaa5a91b158"
integrity sha512-sbXxBnAJWsKv/FEGG6a/WLz7zQYUr1vA2TXvNnPwwJQJCjPwEJMOh1vM22wBr185Phy7D2GD88PcRokn7eUVyw==
"@atproto-labs/did-resolver@0.2.5":
version "0.2.5"
resolved "https://registry.yarnpkg.com/@atproto-labs/did-resolver/-/did-resolver-0.2.5.tgz#74b34e38b10fe1d18a42b35b32909b9a196e9693"
integrity sha512-he7EC6OMSifNs01a4RT9mta/yYitoKDzlK9ty2TFV5Uj/+HpB4vYMRdIDFrRW0Hcsehy90E2t/dw0t7361MEKQ==
dependencies:
"@atproto-labs/fetch" "0.2.3"
"@atproto-labs/pipe" "0.1.1"
"@atproto-labs/simple-store" "0.3.0"
"@atproto-labs/simple-store-memory" "0.1.4"
"@atproto/did" "0.2.3"
"@atproto/did" "0.2.4"
zod "^3.23.8"
"@atproto-labs/fetch-node@0.2.0":
@@ -82,12 +82,12 @@
"@atproto/xrpc" "^0.7.6"
"@atproto/xrpc-server" "^0.10.0"
"@atproto/api@^0.18.13":
version "0.18.13"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.13.tgz#63eee310e6715752eb87748323cf9ab57dd91e4b"
integrity sha512-CULZ01pSJDltLS/Gc9MMrhFzB6OM3ezyZw7KoeLT/sBfwgA1ddA4mWdTh7DIRosPRigXtA05bnoiCutZbQDo+Q==
"@atproto/api@^0.18.13", "@atproto/api@^0.18.15":
version "0.18.15"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.15.tgz#25ce82081216bdefbf5397220de76ac3ba9e3f5d"
integrity sha512-GeaTP7HMRZa8jD6trMuTACa8t2jkFtRmcwWgrB0FT7l9jVCXrKpYupWeIeauEgWHNwWUUiaq3LmCox+HBy8ZMQ==
dependencies:
"@atproto/common-web" "^0.4.11"
"@atproto/common-web" "^0.4.12"
"@atproto/lexicon" "^0.6.0"
"@atproto/syntax" "^0.4.2"
"@atproto/xrpc" "^0.7.7"
@@ -96,26 +96,12 @@
tlds "^1.234.0"
zod "^3.23.8"
"@atproto/api@^0.18.5", "@atproto/api@^0.18.7":
version "0.18.7"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.7.tgz#3175ec8f1909ddcae488183a2180de234e7acce4"
integrity sha512-vUluqN1XU5AX5tgfSJjjjUzALCMq8DjdI0jlIhRYyn2Chb0ZOCU8k0ZTpUAcuDFE2FoxxW4S3kvtlHwLMtN5dQ==
"@atproto/api@^0.18.14", "@atproto/api@^0.18.8":
version "0.18.14"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.14.tgz#8cda4323f46928a651bb8f5cae4994bc47e78885"
integrity sha512-1pWAPbuG3RA1o8uOAwYWZOddvNjuweYOxwTvys1q/r9NCjoGkZY0uJUy1dr6LKFaDk8bjikd2O1cgsRwFfv6Fw==
dependencies:
"@atproto/common-web" "^0.4.7"
"@atproto/lexicon" "^0.6.0"
"@atproto/syntax" "^0.4.2"
"@atproto/xrpc" "^0.7.7"
await-lock "^2.2.2"
multiformats "^9.9.0"
tlds "^1.234.0"
zod "^3.23.8"
"@atproto/api@^0.18.6":
version "0.18.6"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.6.tgz#04c26b97bda01cbe276dea523de6e4a184894c18"
integrity sha512-dkzy2OHSAGgzG9GExvOiwRY73EzVD2AiD3nksng+V6erG0kwLfbmVYjoP9mq9Y16BCXr/7q9lekfogthqU614Q==
dependencies:
"@atproto/common-web" "^0.4.7"
"@atproto/common-web" "^0.4.12"
"@atproto/lexicon" "^0.6.0"
"@atproto/syntax" "^0.4.2"
"@atproto/xrpc" "^0.7.7"
@@ -142,23 +128,23 @@
multiformats "^9.9.0"
uint8arrays "3.0.0"
"@atproto/bsky@^0.0.202":
version "0.0.202"
resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.202.tgz#7b4376dba273a4c1b810846074974d1b11a58e5c"
integrity sha512-QE0T/Vr/WdPZ1lUAZNtNqkH9D+Ii7+95oUR0Q2EYyr0J9G3hlLTvc/FJog91aSe6fzZludRB/n1sRUfdMRePdg==
"@atproto/bsky@^0.0.210":
version "0.0.210"
resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.210.tgz#e9217025479e49d371f80b498992f0599eb2b79f"
integrity sha512-FZ/AWxAvg7BaHE1AZErUZpAD1zA/laOSlaWhk7/E0nKHcXH7mgWXWeE5CJsCT8sSpoLm4GIYV/4wC3lFbSu33g==
dependencies:
"@atproto-labs/fetch-node" "0.2.0"
"@atproto-labs/xrpc-utils" "0.0.24"
"@atproto/api" "^0.18.7"
"@atproto/common" "^0.5.3"
"@atproto/api" "^0.18.13"
"@atproto/common" "^0.5.7"
"@atproto/crypto" "^0.4.5"
"@atproto/did" "^0.2.3"
"@atproto/did" "^0.2.4"
"@atproto/identity" "^0.4.10"
"@atproto/lexicon" "^0.6.0"
"@atproto/repo" "^0.8.12"
"@atproto/sync" "^0.1.39"
"@atproto/syntax" "^0.4.2"
"@atproto/xrpc-server" "^0.10.3"
"@atproto/xrpc-server" "^0.10.8"
"@bufbuild/protobuf" "^1.5.0"
"@connectrpc/connect" "^1.1.4"
"@connectrpc/connect-express" "^1.1.4"
@@ -208,13 +194,13 @@
pino-http "^8.2.1"
typed-emitter "^2.1.0"
"@atproto/common-web@^0.4.11":
version "0.4.11"
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.11.tgz#eb41dc02c1ea4221388630e193d181fb098186e0"
integrity sha512-VHejNmSABU8/03VrQ3e36AmT5U3UIeio+qSUqCrO1oNgrJcWfGy1rpj0FVtUugWF8Un29+yzkukzWGZfXL70rQ==
"@atproto/common-web@^0.4.11", "@atproto/common-web@^0.4.12":
version "0.4.12"
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.12.tgz#04135bef480d9e12cfef124ee45d8236764e7509"
integrity sha512-3aCJemqM/fkHQrVPbTCHCdiVstKFI+2LkFLvUhO6XZP0EqUZa/rg/CIZBKTFUWu9I5iYiaEiXL9VwcDRpEevSw==
dependencies:
"@atproto/lex-data" "0.0.7"
"@atproto/lex-json" "0.0.7"
"@atproto/lex-data" "0.0.8"
"@atproto/lex-json" "0.0.8"
zod "^3.23.8"
"@atproto/common-web@^0.4.4", "@atproto/common-web@^0.4.6":
@@ -279,6 +265,18 @@
multiformats "^9.9.0"
pino "^8.21.0"
"@atproto/common@^0.5.7", "@atproto/common@^0.5.8":
version "0.5.8"
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.5.8.tgz#90d8a99405140885631a380fe24a97be15107c49"
integrity sha512-6BS6OJ/eiN/w8cu3xG1NA/waq9jBsYXZ6pfV85WUDegbfZaGS/IVtpJtjdE7LemE8cJys3AqGFDVJzeXDBQgbw==
dependencies:
"@atproto/common-web" "^0.4.12"
"@atproto/lex-cbor" "0.0.8"
"@atproto/lex-data" "0.0.8"
iso-datestring-validator "^2.2.2"
multiformats "^9.9.0"
pino "^8.21.0"
"@atproto/crypto@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@atproto/crypto/-/crypto-0.1.0.tgz#bc73a479f9dbe06fa025301c182d7f7ab01bc568"
@@ -308,23 +306,23 @@
"@noble/hashes" "^1.6.1"
uint8arrays "3.0.0"
"@atproto/dev-env@^0.3.196":
version "0.3.196"
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.196.tgz#e98d3f3e7a7cee874425b01bfc164e22d1111664"
integrity sha512-65LrDGcGIFrZ+JnQlW38ZFO9jYO1Sp9fHc2rRq6bZzfAGT6c7K3EWHhXhNDtQ4oL3DYAT9+p56Pcv68XMaSipA==
"@atproto/dev-env@^0.3.204":
version "0.3.204"
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.204.tgz#cb7360ee2f6be7301c60d964b6e3f96351808be6"
integrity sha512-MdqkjGVXH2AovnFpHrWgImr7dGtbEKsaVpDxKo2LelJ9Cwc015fc1Mzfa+4h7DHoDwtjiAZIBG7rH8ZWO7hZPQ==
dependencies:
"@atproto/api" "^0.18.7"
"@atproto/bsky" "^0.0.202"
"@atproto/api" "^0.18.13"
"@atproto/bsky" "^0.0.210"
"@atproto/bsync" "^0.0.23"
"@atproto/common-web" "^0.4.7"
"@atproto/common-web" "^0.4.11"
"@atproto/crypto" "^0.4.5"
"@atproto/identity" "^0.4.10"
"@atproto/lexicon" "^0.6.0"
"@atproto/ozone" "^0.1.160"
"@atproto/pds" "^0.4.199"
"@atproto/ozone" "^0.1.161"
"@atproto/pds" "^0.4.203"
"@atproto/sync" "^0.1.39"
"@atproto/syntax" "^0.4.2"
"@atproto/xrpc-server" "^0.10.3"
"@atproto/xrpc-server" "^0.10.8"
"@did-plc/lib" "^0.0.1"
"@did-plc/server" "^0.0.1"
dotenv "^16.0.3"
@@ -334,7 +332,14 @@
uint8arrays "3.0.0"
undici "^6.14.1"
"@atproto/did@0.2.3", "@atproto/did@^0.2.3":
"@atproto/did@0.2.4", "@atproto/did@^0.2.4":
version "0.2.4"
resolved "https://registry.yarnpkg.com/@atproto/did/-/did-0.2.4.tgz#8843b4e53cc1de22e710d4db47170ca2f631dee4"
integrity sha512-nxNiCgXeo7pfjojq9fpfZxCO0X0xUipNVKW+AHNZwQKiUDt6zYL0VXEfm8HBUwQOCmKvj2pRRSM1Cur+tUWk3g==
dependencies:
zod "^3.23.8"
"@atproto/did@^0.2.3":
version "0.2.3"
resolved "https://registry.yarnpkg.com/@atproto/did/-/did-0.2.3.tgz#83cd18ae105324913b30a2259cd9d886677b2d15"
integrity sha512-VI8JJkSizvM2cHYJa37WlbzeCm5tWpojyc1/Zy8q8OOjyoy6X4S4BEfoP941oJcpxpMTObamibQIXQDo7tnIjg==
@@ -374,7 +379,7 @@
multiformats "^9.9.0"
tslib "^2.8.1"
"@atproto/lex-cbor@0.0.3", "@atproto/lex-cbor@^0.0.3":
"@atproto/lex-cbor@0.0.3":
version "0.0.3"
resolved "https://registry.yarnpkg.com/@atproto/lex-cbor/-/lex-cbor-0.0.3.tgz#13712fa5216cd336ebbd31fbd34d35bf6ea21492"
integrity sha512-N8lCV3kK5ZcjSOWxKLWqzlnaSpK4isjXRZ0EqApl/5y9KB64s78hQ/U3KIE5qnPRlBbW5kSH3YACoU27u9nTOA==
@@ -383,14 +388,22 @@
multiformats "^9.9.0"
tslib "^2.8.1"
"@atproto/lex-client@0.0.4":
version "0.0.4"
resolved "https://registry.yarnpkg.com/@atproto/lex-client/-/lex-client-0.0.4.tgz#04fca87296f3177ba122e41bed89d36b4d81b01a"
integrity sha512-tGaenywYo6IvzKKMYuZB+sMBQNDkQ53PLz/NM0WeXnaa/e58VhOGzwVLnNSI/QR9qLlwHFfMf8AIFZyAVHBWCw==
"@atproto/lex-cbor@0.0.8", "@atproto/lex-cbor@^0.0.8":
version "0.0.8"
resolved "https://registry.yarnpkg.com/@atproto/lex-cbor/-/lex-cbor-0.0.8.tgz#191e3443bb3157618f4b66637b305c6127ab5c28"
integrity sha512-WFUkNTLUMunPaA+NkD2INwfhrgo5fAMz7zSk2ncoqbK2AS78X2ith8TJSevY0ynPukbFmaJ9BdauzCpWQ4ZIqQ==
dependencies:
"@atproto/lex-data" "0.0.3"
"@atproto/lex-json" "0.0.3"
"@atproto/lex-schema" "0.0.4"
"@atproto/lex-data" "0.0.8"
tslib "^2.8.1"
"@atproto/lex-client@0.0.9":
version "0.0.9"
resolved "https://registry.yarnpkg.com/@atproto/lex-client/-/lex-client-0.0.9.tgz#595cc805583587bcd19d1ce3a7957d15289c0e0b"
integrity sha512-30WtEedG0s/JNkbHzxpObkUg0id4+/p1O7LcUVCQWrNhWRw/hCzhHySSgFKKIVeLKAYIrZmaWt1XlAdNhGO7DQ==
dependencies:
"@atproto/lex-data" "0.0.8"
"@atproto/lex-json" "0.0.8"
"@atproto/lex-schema" "0.0.9"
tslib "^2.8.1"
"@atproto/lex-data@0.0.2":
@@ -404,7 +417,7 @@
uint8arrays "3.0.0"
unicode-segmenter "^0.14.0"
"@atproto/lex-data@0.0.3", "@atproto/lex-data@^0.0.3":
"@atproto/lex-data@0.0.3":
version "0.0.3"
resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.3.tgz#1ce1bd19e17af41b2991a2a02ad439a136dec4e9"
integrity sha512-ivo1IpY/EX+RIpxPgCf4cPhQo5bfu4nrpa1vJCt8hCm9SfoonJkDFGa0n4SMw4JnXZoUcGcrJ46L+D8bH6GI2g==
@@ -415,10 +428,10 @@
uint8arrays "3.0.0"
unicode-segmenter "^0.14.0"
"@atproto/lex-data@0.0.7":
version "0.0.7"
resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.7.tgz#6aa87423f6d47849bec8ff3ca0b00ce93964adc8"
integrity sha512-W/Q5o9o7n2Sv3UywckChu01X5lwQUtaiiOkGJLnRsdkQTyC6813nPgY+p2sG7NwwM+82lu+FUV9fE/Ul3VqaJw==
"@atproto/lex-data@0.0.8", "@atproto/lex-data@^0.0.8":
version "0.0.8"
resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.8.tgz#46cc261efbfa6cc05bf04439d2d73cd8386b467d"
integrity sha512-1Y5tz7BkS7380QuLNXaE8GW8Xba+mRWugt8BKM4BUFYjjUZdmirU8lr72iM4XlEBrzRu8Cfvj+MbsbYaZv+IgA==
dependencies:
"@atproto/syntax" "0.4.2"
multiformats "^9.9.0"
@@ -426,12 +439,12 @@
uint8arrays "3.0.0"
unicode-segmenter "^0.14.0"
"@atproto/lex-document@0.0.5":
version "0.0.5"
resolved "https://registry.yarnpkg.com/@atproto/lex-document/-/lex-document-0.0.5.tgz#8d4851b351149ba673c1de9c1898c3c9ebd8b4b3"
integrity sha512-faGcwsupdtvoFZ8ILEu14MYJ0z/pCiQ+Yu4WEkVtJvy8jIkFxeZ8MxxZgm8KrlSt6jxsyYvMpscQCtYpVmafFQ==
"@atproto/lex-document@0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@atproto/lex-document/-/lex-document-0.0.10.tgz#a58cdd79fc49e411d4024037f18a2ad0297887e1"
integrity sha512-GrvO36UyWhStSNN0CtVswMyzYK7eUA0zLjYJRqpghAyzYV9ZVXTUL1Vx79MUSg3tC1jDM1A0hmtsjE1Cyo2rHQ==
dependencies:
"@atproto/lex-schema" "0.0.4"
"@atproto/lex-schema" "0.0.9"
core-js "^3"
tslib "^2.8.1"
@@ -451,35 +464,35 @@
"@atproto/lex-data" "0.0.3"
tslib "^2.8.1"
"@atproto/lex-json@0.0.7":
version "0.0.7"
resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.7.tgz#c06e1fc3e06d739bbb74694f5d846055bed37866"
integrity sha512-bjNPD5M/MhLfjNM7tcxuls80UgXpHqxdOxDXEUouAtZQV/nIDhGjmNUvKxOmOgnDsiZRnT2g5y3onrnjH3a44g==
"@atproto/lex-json@0.0.8":
version "0.0.8"
resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.8.tgz#03290762d9368b029488ee0a0766d1a34063255c"
integrity sha512-w1Qmkae1QhmNz+i1Zm3xr3jp0UPPRENmdlpU0qIrdxWDo9W4Mzkeyc3eSoa+Zs+zN8xkRSQw7RLZte/B7Ipdwg==
dependencies:
"@atproto/lex-data" "0.0.7"
"@atproto/lex-data" "0.0.8"
tslib "^2.8.1"
"@atproto/lex-resolver@0.0.5":
version "0.0.5"
resolved "https://registry.yarnpkg.com/@atproto/lex-resolver/-/lex-resolver-0.0.5.tgz#9d0645c43423cb99b491ca8e658770395d2cd630"
integrity sha512-gBqHY1HS/g2rxuk8CPzB1cmMxmOWK897/jIboMhYDXnuavg8zh54eljCFXr8GvaJdo5DnMGyBEilHNppJOD4mg==
"@atproto/lex-resolver@0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@atproto/lex-resolver/-/lex-resolver-0.0.10.tgz#2139836dd80a5f5d31979d9fb2f10872a4a844b6"
integrity sha512-7cV/vjJGHMUbzv1y2kIOChC/B5ox1F5hKpG3fFZdkM8eZ5o9NuMiIojvTgCYdxyH+XA/X6qjblMrru73fFRtmA==
dependencies:
"@atproto-labs/did-resolver" "0.2.4"
"@atproto-labs/did-resolver" "0.2.5"
"@atproto/crypto" "0.4.5"
"@atproto/lex-client" "0.0.4"
"@atproto/lex-data" "0.0.3"
"@atproto/lex-document" "0.0.5"
"@atproto/lex-schema" "0.0.4"
"@atproto/lex-client" "0.0.9"
"@atproto/lex-data" "0.0.8"
"@atproto/lex-document" "0.0.10"
"@atproto/lex-schema" "0.0.9"
"@atproto/repo" "0.8.12"
"@atproto/syntax" "0.4.2"
tslib "^2.8.1"
"@atproto/lex-schema@0.0.4":
version "0.0.4"
resolved "https://registry.yarnpkg.com/@atproto/lex-schema/-/lex-schema-0.0.4.tgz#382998f5c384b864a1bc0c00bd073c1168503895"
integrity sha512-WlF+w/OH16KR9XYW9J7hNEDgHp37uG2EWm8/iJknDFsWYjbhgl71x1jcqqCM8BcDxAYxyJLOvPuadOC6cG5JjA==
"@atproto/lex-schema@0.0.9":
version "0.0.9"
resolved "https://registry.yarnpkg.com/@atproto/lex-schema/-/lex-schema-0.0.9.tgz#b47173092d4ee9850b1fe013963b18b3a8bcf1ed"
integrity sha512-nsXpG0BdWu5Qn8qgs/+tHKP2gdVoYNiYwIUl+lt6EFb5juuOScmPilhZOa9MPDpLLzVgl35x9whjh842CvpHJQ==
dependencies:
"@atproto/lex-data" "0.0.3"
"@atproto/lex-data" "0.0.8"
"@atproto/syntax" "0.4.2"
tslib "^2.8.1"
@@ -505,49 +518,49 @@
multiformats "^9.9.0"
zod "^3.23.8"
"@atproto/oauth-provider-api@0.3.4":
version "0.3.4"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.3.4.tgz#bce9b1a5a6bd759b0de8f6b80c4aced9b64f0c79"
integrity sha512-K3gBqyf9VlYE6tvfD0EDya9WQ9XWtbuhxkI1XHyCIyAvAemhBGoJ1As0ESo3UpJmd2JhA2DmLj4oOvBqknamBA==
"@atproto/oauth-provider-api@0.3.6":
version "0.3.6"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.3.6.tgz#ab8ee8f86c3eac7c6008f786205b7f149678f85a"
integrity sha512-ddUCbBH/1X+3YaegJzOTESAc8+ZUwn0sLgKpxp4Na3J6cPeZfXiheWGKxbu5pTPwJr1msCNOakqSMkoLbP7UEA==
dependencies:
"@atproto/jwk" "0.6.0"
"@atproto/oauth-types" "0.5.2"
"@atproto/oauth-types" "0.6.1"
"@atproto/oauth-provider-frontend@0.2.5":
version "0.2.5"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.2.5.tgz#6a62475bf70cc9d198e8e5f6683fb0c8d39797a0"
integrity sha512-9+23B2Wp2G5UvHPiKQGwoK3sOu3JHa+jVfWjbUkXhho0HGL60hAbyrdm0C6n3UER/mLfn8MTjzW9jQSuJXHosg==
"@atproto/oauth-provider-frontend@0.2.7":
version "0.2.7"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.2.7.tgz#b4157fea9fffd970c357f25ed7588359e820d94a"
integrity sha512-cXS/lonP0WzPEcCiv1BuMUyp1Oq+eZpwX3sbvhsNYwtyM+7PLU1KhD9y2VR01S7UhCxRPmpQiIMLvsdVWNddZA==
optionalDependencies:
"@atproto/oauth-provider-api" "0.3.4"
"@atproto/oauth-provider-api" "0.3.6"
"@atproto/oauth-provider-ui@0.3.6":
version "0.3.6"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.3.6.tgz#1181040d33b19ed7124f5ad12833200bcd7892e6"
integrity sha512-uxnBWEX/Ht2JJbeibMhCu3OatKchhQGV4v5KfXzTylX2VIZrRmG8PVr5YnHmijjJZD+NgeDWlFSdyGdZZ7qU9w==
"@atproto/oauth-provider-ui@0.4.1":
version "0.4.1"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.4.1.tgz#5f957f1eed89409ba842bffb5b7e9bf25c6bcc44"
integrity sha512-70K0uwCCz68qK5bwce8NG6RsBp1pBp/YMzZUGq09XhYVwx/wHgaf1HUFuwLIGV4sVKgahLsNBrRHY9n7fXFbRA==
optionalDependencies:
"@atproto/oauth-provider-api" "0.3.4"
"@atproto/oauth-provider-api" "0.3.6"
"@atproto/oauth-provider@^0.14.1":
version "0.14.1"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.14.1.tgz#b1fd01408bb988ee29f19e2ab6ab323a31ea7590"
integrity sha512-n0qHOhUfSwp6w3i54tC5IjmeE5raoxEyxchJkqvoj/xWBiysdMJwpLTIvQ90rcXbjIA22Jv58oQ/mtx07w82xA==
"@atproto/oauth-provider@^0.15.4":
version "0.15.4"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.15.4.tgz#eb89d0458f39f3a6c1445b98cbfd5367027fdd26"
integrity sha512-Ypa6WnG8SiTDfQWXF0MUcZJg/4A9jXObWHi4IAd0FZj9kN/vln8hzq8BSQpyAn3Yy8+XVoaKyN/jhzwxkqQ9Sw==
dependencies:
"@atproto-labs/fetch" "0.2.3"
"@atproto-labs/fetch-node" "0.2.0"
"@atproto-labs/pipe" "0.1.1"
"@atproto-labs/simple-store" "0.3.0"
"@atproto-labs/simple-store-memory" "0.1.4"
"@atproto/common" "^0.5.3"
"@atproto/did" "0.2.3"
"@atproto/common" "^0.5.8"
"@atproto/did" "0.2.4"
"@atproto/jwk" "0.6.0"
"@atproto/jwk-jose" "0.1.11"
"@atproto/lex-document" "0.0.5"
"@atproto/lex-resolver" "0.0.5"
"@atproto/oauth-provider-api" "0.3.4"
"@atproto/oauth-provider-frontend" "0.2.5"
"@atproto/oauth-provider-ui" "0.3.6"
"@atproto/lex-document" "0.0.10"
"@atproto/lex-resolver" "0.0.10"
"@atproto/oauth-provider-api" "0.3.6"
"@atproto/oauth-provider-frontend" "0.2.7"
"@atproto/oauth-provider-ui" "0.4.1"
"@atproto/oauth-scopes" "0.3.0"
"@atproto/oauth-types" "0.5.2"
"@atproto/oauth-types" "0.6.1"
"@atproto/syntax" "0.4.2"
"@hapi/accept" "^6.0.3"
"@hapi/address" "^5.1.1"
@@ -569,29 +582,29 @@
"@atproto/did" "^0.2.3"
"@atproto/syntax" "^0.4.2"
"@atproto/oauth-types@0.5.2":
version "0.5.2"
resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.5.2.tgz#443d2b004403f33fbdcbe4f3406f645c2785fe04"
integrity sha512-9DCDvtvCanTwAaU5UakYDO0hzcOITS3RutK5zfLytE5Y9unj0REmTDdN8Xd8YCfUJl7T/9pYpf04Uyq7bFTASg==
"@atproto/oauth-types@0.6.1":
version "0.6.1"
resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.6.1.tgz#c75be82016052fd2f877030a4f4832af7d7d7016"
integrity sha512-3z92GN/6zCq9E2GTTfZM27tWEbvi1qwFSA7KoS5+wqBC4kSsLvnLxmbKH402Z40DfWS4YWqw0DkHsgP0LNFDEA==
dependencies:
"@atproto/did" "0.2.3"
"@atproto/did" "0.2.4"
"@atproto/jwk" "0.6.0"
zod "^3.23.8"
"@atproto/ozone@^0.1.160":
version "0.1.160"
resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.160.tgz#1131fa6c0d1b37e3a7b0aaa2f788b36eb2f7a750"
integrity sha512-p0CP5/1Anv1qbxoRBC4IOaSkFe28Y3nKmzfrx0Z6in/o8VF/LZ9dhuW1UgrDQs4PyMzMOar3UFfO2ZUL4Gg58A==
"@atproto/ozone@^0.1.161":
version "0.1.161"
resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.161.tgz#31f776c168c4eba8e095ebcf8f4cfbd13f6f175c"
integrity sha512-gGbo0sbopWnW2zkpeGBrERLuAeI0lhWmnAKoHK3g1/F/r3V/8k2MRAEMq7bhwOY7uCVoZq4ob4sX+pCNPUM8AQ==
dependencies:
"@atproto/api" "^0.18.5"
"@atproto/api" "^0.18.8"
"@atproto/common" "^0.5.3"
"@atproto/crypto" "^0.4.5"
"@atproto/identity" "^0.4.10"
"@atproto/lexicon" "^0.6.0"
"@atproto/syntax" "^0.4.2"
"@atproto/ws-client" "^0.0.3"
"@atproto/ws-client" "^0.0.4"
"@atproto/xrpc" "^0.7.7"
"@atproto/xrpc-server" "^0.10.3"
"@atproto/xrpc-server" "^0.10.4"
"@did-plc/lib" "^0.0.1"
compression "^1.7.4"
cors "^2.8.5"
@@ -609,30 +622,30 @@
undici "^6.14.1"
ws "^8.12.0"
"@atproto/pds@^0.4.199":
version "0.4.199"
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.199.tgz#8cdf10ad449c57206b4820191ecfb358346908bd"
integrity sha512-EtXVzPusvLVRIhjUutKcb75lPN9oWD24bZGbRrDaehcbI0QXbosGek/vgil0ZFLn0YW3G/BssM0K63KUotscrw==
"@atproto/pds@^0.4.203":
version "0.4.204"
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.204.tgz#d6f6280b7c21bdac2a80470ec187f726a5c94c63"
integrity sha512-bADjh9TWbfyN/FGPRmolvDPatmfoWfv3pstlujEXBi3ja67nJAbhdtqrMesVAltmD8GzrLw1/APrYoYJ0Pi8GA==
dependencies:
"@atproto-labs/fetch-node" "0.2.0"
"@atproto-labs/simple-store" "0.3.0"
"@atproto-labs/simple-store-memory" "0.1.4"
"@atproto-labs/simple-store-redis" "0.0.1"
"@atproto-labs/xrpc-utils" "0.0.24"
"@atproto/api" "^0.18.6"
"@atproto/api" "^0.18.14"
"@atproto/aws" "^0.2.31"
"@atproto/common" "^0.5.3"
"@atproto/common" "^0.5.8"
"@atproto/crypto" "^0.4.5"
"@atproto/identity" "^0.4.10"
"@atproto/lex-cbor" "^0.0.3"
"@atproto/lex-data" "^0.0.3"
"@atproto/lex-cbor" "^0.0.8"
"@atproto/lex-data" "^0.0.8"
"@atproto/lexicon" "^0.6.0"
"@atproto/oauth-provider" "^0.14.1"
"@atproto/oauth-provider" "^0.15.4"
"@atproto/oauth-scopes" "^0.3.0"
"@atproto/repo" "^0.8.12"
"@atproto/syntax" "^0.4.2"
"@atproto/xrpc" "^0.7.7"
"@atproto/xrpc-server" "^0.10.3"
"@atproto/xrpc-server" "^0.10.9"
"@did-plc/lib" "^0.0.4"
"@hapi/address" "^5.1.1"
better-sqlite3 "^10.0.0"
@@ -725,6 +738,14 @@
"@atproto/common" "^0.5.0"
ws "^8.12.0"
"@atproto/ws-client@^0.0.4":
version "0.0.4"
resolved "https://registry.yarnpkg.com/@atproto/ws-client/-/ws-client-0.0.4.tgz#9e436c0e72abea5da0d5a7e8ec862cec0fdb10cd"
integrity sha512-dox1XIymuC7/ZRhUqKezIGgooZS45C6vHCfu0PnWjfvsLCK2kAlnvX4IBkA/WpcoijDhQ9ejChnFbo/sLmgvAg==
dependencies:
"@atproto/common" "^0.5.3"
ws "^8.12.0"
"@atproto/xrpc-server@^0.10.0":
version "0.10.2"
resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.10.2.tgz#b68f42a7b6df5bb8081525e5c981a709b9b02739"
@@ -763,6 +784,25 @@
ws "^8.12.0"
zod "^3.23.8"
"@atproto/xrpc-server@^0.10.4", "@atproto/xrpc-server@^0.10.8", "@atproto/xrpc-server@^0.10.9":
version "0.10.9"
resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.10.9.tgz#d1047c49c458a99442ba6516541fd9ff0f23299e"
integrity sha512-6PVlkbvGitKAAqR9Lt8KzLp/2c8RHjpJdJ3OBM5baHUwpej7nKESyXvO5bk/GZHTvIcp0Vg0b45spn3/F9tsWg==
dependencies:
"@atproto/common" "^0.5.8"
"@atproto/crypto" "^0.4.5"
"@atproto/lex-cbor" "0.0.8"
"@atproto/lex-data" "0.0.8"
"@atproto/lexicon" "^0.6.0"
"@atproto/ws-client" "^0.0.4"
"@atproto/xrpc" "^0.7.7"
express "^4.17.2"
http-errors "^2.0.0"
mime-types "^2.1.35"
rate-limiter-flexible "^2.4.1"
ws "^8.12.0"
zod "^3.23.8"
"@atproto/xrpc@^0.7.6":
version "0.7.6"
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.6.tgz#bc12b0e37f81fa76589691634d4fac9774fd0cb5"
@@ -6144,20 +6184,13 @@
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb"
integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==
"@react-native-async-storage/async-storage@2.2.0":
"@react-native-async-storage/async-storage@2.2.0", "@react-native-async-storage/async-storage@^1.15.2":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz#a3aa565253e46286655560172f4e366e8969f5ad"
integrity sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==
dependencies:
merge-options "^3.0.4"
"@react-native-async-storage/async-storage@^1.15.2":
version "1.22.0"
resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-1.22.0.tgz#202a9afd15a5b829c39b709d0ca3942612441efc"
integrity sha512-b5KD010iiZnot86RbAaHpLuHwmPW2qA3SSN/OSZhd1kBoINEQEVBuv+uFtcaTxAhX27bT0wd13GOb2IOSDUXSA==
dependencies:
merge-options "^3.0.4"
"@react-native/assets-registry@0.81.5":
version "0.81.5"
resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.81.5.tgz#d22c924fa6f6d4a463c5af34ce91f38756c0fa7d"