Merge remote-tracking branch 'origin/main' into app-1759

* origin/main:
  [APP-1767] Live event feeds (#9696)
  Improved client events for feed interactions (#9695)
  Reduce startup hang by not querying fonts (#9670)
  pin AsyncStorage to v2.2.0 (#9656)
  Add comprehensive CLAUDE.md development guide (#9666)
  Nightly source-language update
  Expose QueryClient for TanStack Query DevTools browser extension (#9678)
  Do not render links if uri is invalid (#9663)
  Standardize metadata for client events in feeds (#9653)
  Set up conductor.json with yarn install and web server configuration (#9676)
  Nightly source-language update
  Cleaner sidebar layout (#9603)
  Set Claude model to Opus (#9672)
  Delete claude-code-review.yml (#9671)
  [APP-1750] Add the ability to report livestreams (#9654)
  Add Claude Code GitHub Workflow (#9667)
  Nightly source-language update
This commit is contained in:
Eric Bailey
2026-01-14 15:15:11 -06:00
61 changed files with 2947 additions and 680 deletions
+5 -2
View File
@@ -34,5 +34,8 @@ EXPO_PUBLIC_SENTRY_DSN=
# Bitdrift API key. If undefined, Bitdrift will be disabled. # Bitdrift API key. If undefined, Bitdrift will be disabled.
EXPO_PUBLIC_BITDRIFT_API_KEY= EXPO_PUBLIC_BITDRIFT_API_KEY=
# bapp-config web worker URL # geolocation web worker URL
BAPP_CONFIG_DEV_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.Admonition', 'Admonition.Admonition',
'Toast.Action', 'Toast.Action',
'toast.Action',
'AgeAssuranceAdmonition', 'AgeAssuranceAdmonition',
'Span', 'Span',
'StackedButton', 'StackedButton',
+54
View File
@@ -0,0 +1,54 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
# NOTE(sfn): we can add a custom system prompt here
claude_args: |
--model claude-opus-4-5-20251101
+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` |
+6
View File
@@ -0,0 +1,6 @@
{
"scripts": {
"setup": "yarn install",
"run": "yarn web --port $CONDUCTOR_PORT"
}
}
+2 -1
View File
@@ -73,7 +73,7 @@
"icons:optimize": "svgo -f ./assets/icons" "icons:optimize": "svgo -f ./assets/icons"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.18.13", "@atproto/api": "^0.18.15",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.6", "@bsky.app/alf": "^0.1.6",
@@ -284,6 +284,7 @@
"@react-native/babel-preset": "0.81.5", "@react-native/babel-preset": "0.81.5",
"@react-native/normalize-colors": "0.81.5", "@react-native/normalize-colors": "0.81.5",
"**/@expo/image-utils": "0.8.7", "**/@expo/image-utils": "0.8.7",
"**/@react-native-async-storage/async-storage": "2.2.0",
"**/expo-constants": "18.0.8", "**/expo-constants": "18.0.8",
"**/expo-device": "7.1.4", "**/expo-device": "7.1.4",
"**/zod": "3.23.8", "**/zod": "3.23.8",
+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 {ToastOutlet} from '#/components/Toast'
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance' import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
import {prefetchAgeAssuranceConfig} from '#/ageAssurance' import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
import {
prefetchLiveEvents,
Provider as LiveEventsProvider,
} from '#/features/liveEvents/context'
import * as Geo from '#/geolocation' import * as Geo from '#/geolocation'
import {Splash} from '#/Splash' import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet' import {BottomSheetProvider} from '../modules/bottom-sheet'
@@ -92,6 +96,7 @@ if (isAndroid) {
*/ */
Geo.resolve() Geo.resolve()
prefetchAgeAssuranceConfig() prefetchAgeAssuranceConfig()
prefetchLiveEvents()
function InnerApp() { function InnerApp() {
const [isReady, setIsReady] = React.useState(false) const [isReady, setIsReady] = React.useState(false)
@@ -141,49 +146,51 @@ function InnerApp() {
<QueryProvider currentDid={currentAccount?.did}> <QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider> <PolicyUpdateOverlayProvider>
<StatsigProvider> <StatsigProvider>
<AgeAssuranceV2Provider> <LiveEventsProvider>
<ComposerProvider> <AgeAssuranceV2Provider>
<MessagesProvider> <ComposerProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */} <MessagesProvider>
<LabelDefsProvider> {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<ModerationOptsProvider> <LabelDefsProvider>
<LoggedOutViewProvider> <ModerationOptsProvider>
<SelectedFeedProvider> <LoggedOutViewProvider>
<HiddenRepliesProvider> <SelectedFeedProvider>
<HomeBadgeProvider> <HiddenRepliesProvider>
<UnreadNotifsProvider> <HomeBadgeProvider>
<BackgroundNotificationPreferencesProvider> <UnreadNotifsProvider>
<MutedThreadsProvider> <BackgroundNotificationPreferencesProvider>
<ProgressGuideProvider> <MutedThreadsProvider>
<ServiceAccountManager> <ProgressGuideProvider>
<EmailVerificationProvider> <ServiceAccountManager>
<HideBottomBarBorderProvider> <EmailVerificationProvider>
<GestureHandlerRootView <HideBottomBarBorderProvider>
style={s.h100pct}> <GestureHandlerRootView
<GlobalGestureEventsProvider> style={s.h100pct}>
<IntentDialogProvider> <GlobalGestureEventsProvider>
<TestCtrls /> <IntentDialogProvider>
<Shell /> <TestCtrls />
<ToastOutlet /> <Shell />
</IntentDialogProvider> <ToastOutlet />
</GlobalGestureEventsProvider> </IntentDialogProvider>
</GestureHandlerRootView> </GlobalGestureEventsProvider>
</HideBottomBarBorderProvider> </GestureHandlerRootView>
</EmailVerificationProvider> </HideBottomBarBorderProvider>
</ServiceAccountManager> </EmailVerificationProvider>
</ProgressGuideProvider> </ServiceAccountManager>
</MutedThreadsProvider> </ProgressGuideProvider>
</BackgroundNotificationPreferencesProvider> </MutedThreadsProvider>
</UnreadNotifsProvider> </BackgroundNotificationPreferencesProvider>
</HomeBadgeProvider> </UnreadNotifsProvider>
</HiddenRepliesProvider> </HomeBadgeProvider>
</SelectedFeedProvider> </HiddenRepliesProvider>
</LoggedOutViewProvider> </SelectedFeedProvider>
</ModerationOptsProvider> </LoggedOutViewProvider>
</LabelDefsProvider> </ModerationOptsProvider>
</MessagesProvider> </LabelDefsProvider>
</ComposerProvider> </MessagesProvider>
</AgeAssuranceV2Provider> </ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</StatsigProvider> </StatsigProvider>
</PolicyUpdateOverlayProvider> </PolicyUpdateOverlayProvider>
</QueryProvider> </QueryProvider>
+46 -39
View File
@@ -57,6 +57,10 @@ import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbe
import {ToastOutlet} from '#/components/Toast' import {ToastOutlet} from '#/components/Toast'
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance' import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
import {prefetchAgeAssuranceConfig} from '#/ageAssurance' import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
import {
prefetchLiveEvents,
Provider as LiveEventsProvider,
} from '#/features/liveEvents/context'
import * as Geo from '#/geolocation' import * as Geo from '#/geolocation'
import {Splash} from '#/Splash' import {Splash} from '#/Splash'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -67,6 +71,7 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
*/ */
Geo.resolve() Geo.resolve()
prefetchAgeAssuranceConfig() prefetchAgeAssuranceConfig()
prefetchLiveEvents()
function InnerApp() { function InnerApp() {
const [isReady, setIsReady] = React.useState(false) const [isReady, setIsReady] = React.useState(false)
@@ -117,45 +122,47 @@ function InnerApp() {
<QueryProvider currentDid={currentAccount?.did}> <QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider> <PolicyUpdateOverlayProvider>
<StatsigProvider> <StatsigProvider>
<AgeAssuranceV2Provider> <LiveEventsProvider>
<ComposerProvider> <AgeAssuranceV2Provider>
<MessagesProvider> <ComposerProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */} <MessagesProvider>
<LabelDefsProvider> {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<ModerationOptsProvider> <LabelDefsProvider>
<LoggedOutViewProvider> <ModerationOptsProvider>
<SelectedFeedProvider> <LoggedOutViewProvider>
<HiddenRepliesProvider> <SelectedFeedProvider>
<HomeBadgeProvider> <HiddenRepliesProvider>
<UnreadNotifsProvider> <HomeBadgeProvider>
<BackgroundNotificationPreferencesProvider> <UnreadNotifsProvider>
<MutedThreadsProvider> <BackgroundNotificationPreferencesProvider>
<SafeAreaProvider> <MutedThreadsProvider>
<ProgressGuideProvider> <SafeAreaProvider>
<ServiceConfigProvider> <ProgressGuideProvider>
<EmailVerificationProvider> <ServiceConfigProvider>
<HideBottomBarBorderProvider> <EmailVerificationProvider>
<IntentDialogProvider> <HideBottomBarBorderProvider>
<Shell /> <IntentDialogProvider>
<ToastOutlet /> <Shell />
</IntentDialogProvider> <ToastOutlet />
</HideBottomBarBorderProvider> </IntentDialogProvider>
</EmailVerificationProvider> </HideBottomBarBorderProvider>
</ServiceConfigProvider> </EmailVerificationProvider>
</ProgressGuideProvider> </ServiceConfigProvider>
</SafeAreaProvider> </ProgressGuideProvider>
</MutedThreadsProvider> </SafeAreaProvider>
</BackgroundNotificationPreferencesProvider> </MutedThreadsProvider>
</UnreadNotifsProvider> </BackgroundNotificationPreferencesProvider>
</HomeBadgeProvider> </UnreadNotifsProvider>
</HiddenRepliesProvider> </HomeBadgeProvider>
</SelectedFeedProvider> </HiddenRepliesProvider>
</LoggedOutViewProvider> </SelectedFeedProvider>
</ModerationOptsProvider> </LoggedOutViewProvider>
</LabelDefsProvider> </ModerationOptsProvider>
</MessagesProvider> </LabelDefsProvider>
</ComposerProvider> </MessagesProvider>
</AgeAssuranceV2Provider> </ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</StatsigProvider> </StatsigProvider>
</PolicyUpdateOverlayProvider> </PolicyUpdateOverlayProvider>
</QueryProvider> </QueryProvider>
+6 -1
View File
@@ -11,7 +11,12 @@ import {
import {themes} from '#/alf/themes' import {themes} from '#/alf/themes'
import {type Device} from '#/storage' 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 {atoms} from '#/alf/atoms'
export * from '#/alf/breakpoints' export * from '#/alf/breakpoints'
export * from '#/alf/fonts' export * from '#/alf/fonts'
+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 GestureResponderEvent, View} from 'react-native'
import { import {
type AppBskyFeedDefs, type AppBskyFeedDefs,
@@ -21,19 +21,21 @@ import {
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, select, useTheme} from '#/alf'
import { import {
Button, Button,
ButtonIcon, ButtonIcon,
type ButtonProps, type ButtonProps,
ButtonText, ButtonText,
} from '#/components/Button' } 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 {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
import {Link as InternalLink, type LinkProps} from '#/components/Link' import {Link as InternalLink, type LinkProps} from '#/components/Link'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {RichText, type RichTextProps} from '#/components/RichText' import {RichText, type RichTextProps} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useActiveLiveEventFeedUris} from '#/features/liveEvents/context'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash'
@@ -49,7 +51,11 @@ export function Default(props: Props) {
<Outer> <Outer>
<Header> <Header>
<Avatar src={view.avatar} /> <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 /> <SaveButton view={view} pin />
</Header> </Header>
<Description description={view.description} /> <Description description={view.description} />
@@ -118,14 +124,40 @@ export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
export function TitleAndByline({ export function TitleAndByline({
title, title,
creator, creator,
uri,
}: { }: {
title: string title: string
creator?: bsky.profile.AnyProfileView creator?: bsky.profile.AnyProfileView
uri?: string
}) { }) {
const t = useTheme() 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 ( return (
<View style={[a.flex_1]}> <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 <Text
emoji emoji
style={[a.text_md, a.font_semi_bold, a.leading_snug]} style={[a.text_md, a.font_semi_bold, a.leading_snug]}
+9 -1
View File
@@ -842,6 +842,7 @@ export function SuggestedFeeds() {
<FeedCard.TitleAndByline <FeedCard.TitleAndByline
title={feed.displayName} title={feed.displayName}
creator={feed.creator} creator={feed.creator}
uri={feed.uri}
/> />
</FeedCard.Header> </FeedCard.Header>
<FeedCard.Description <FeedCard.Description
@@ -933,8 +934,15 @@ export function SuggestedFeeds() {
export function ProgressGuide() { export function ProgressGuide() {
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints()
return ( return (
<View style={[t.atoms.border_contrast_low, a.px_lg, a.py_lg, a.pb_lg]}> <View
style={[
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
!gtMobile && {marginTop: 4},
]}>
<ProgressGuideList /> <ProgressGuideList />
</View> </View>
) )
+3 -3
View File
@@ -17,16 +17,16 @@ export function FeedEmbed({
return ( return (
<FeedCard.Link <FeedCard.Link
view={embed.view} 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.Outer>
<FeedCard.Header> <FeedCard.Header>
<FeedCard.Avatar src={embed.view.avatar} /> <FeedCard.Avatar src={embed.view.avatar} size={48} />
<FeedCard.TitleAndByline <FeedCard.TitleAndByline
title={embed.view.displayName} title={embed.view.displayName}
creator={embed.view.creator} creator={embed.view.creator}
uri={embed.view.uri}
/> />
</FeedCard.Header> </FeedCard.Header>
<FeedCard.Likes count={embed.view.likeCount || 0} />
</FeedCard.Outer> </FeedCard.Outer>
</FeedCard.Link> </FeedCard.Link>
) )
+14 -2
View File
@@ -8,6 +8,7 @@ import type React from 'react'
import {useCleanError} from '#/lib/hooks/useCleanError' import {useCleanError} from '#/lib/hooks/useCleanError'
import {logger} from '#/logger' import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/post-shadow' import {type Shadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation' import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation'
import {useRequireAuth} from '#/state/session' import {useRequireAuth} from '#/state/session'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
@@ -32,6 +33,7 @@ export const BookmarkButton = memo(function BookmarkButton({
const {mutateAsync: bookmark} = useBookmarkMutation() const {mutateAsync: bookmark} = useBookmarkMutation()
const cleanError = useCleanError() const cleanError = useCleanError()
const requireAuth = useRequireAuth() const requireAuth = useRequireAuth()
const {feedDescriptor} = useFeedFeedbackContext()
const {viewer} = post const {viewer} = post
const isBookmarked = !!viewer?.bookmarked const isBookmarked = !!viewer?.bookmarked
@@ -50,7 +52,12 @@ export const BookmarkButton = memo(function BookmarkButton({
post, post,
}) })
logger.metric('post:bookmark', {logContext}) logger.metric('post:bookmark', {
uri: post.uri,
authorDid: post.author.did,
logContext,
feedDescriptor,
})
toast.show( toast.show(
<toast.Outer> <toast.Outer>
@@ -85,7 +92,12 @@ export const BookmarkButton = memo(function BookmarkButton({
uri: post.uri, uri: post.uri,
}) })
logger.metric('post:unbookmark', {logContext}) logger.metric('post:unbookmark', {
uri: post.uri,
authorDid: post.author.did,
logContext,
feedDescriptor,
})
toast.show( toast.show(
<toast.Outer> <toast.Outer>
@@ -98,6 +98,7 @@ let PostMenuItems = ({
richText, richText,
threadgateRecord, threadgateRecord,
onShowLess, onShowLess,
logContext,
}: { }: {
testID: string testID: string
post: Shadow<AppBskyFeedDefs.PostView> post: Shadow<AppBskyFeedDefs.PostView>
@@ -111,6 +112,7 @@ let PostMenuItems = ({
timestamp: string timestamp: string
threadgateRecord?: AppBskyFeedThreadgate.Record threadgateRecord?: AppBskyFeedThreadgate.Record
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
}): React.ReactNode => { }): React.ReactNode => {
const {hasSession, currentAccount} = useSession() const {hasSession, currentAccount} = useSession()
const {_} = useLingui() const {_} = useLingui()
@@ -210,9 +212,21 @@ let PostMenuItems = ({
try { try {
if (isThreadMuted) { if (isThreadMuted) {
unmuteThread() unmuteThread()
logger.metric('post:unmute', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
Toast.show(_(msg`You will now receive notifications for this thread`)) Toast.show(_(msg`You will now receive notifications for this thread`))
} else { } else {
muteThread() muteThread()
logger.metric('post:mute', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
Toast.show( Toast.show(
_(msg`You will no longer receive notifications for this thread`), _(msg`You will no longer receive notifications for this thread`),
) )
@@ -272,6 +286,12 @@ let PostMenuItems = ({
feedContext: postFeedContext, feedContext: postFeedContext,
reqId: postReqId, reqId: postReqId,
}) })
logger.metric('post:showMore', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
Toast.show( Toast.show(
_(msg({message: 'Feedback sent to feed operator', context: 'toast'})), _(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
) )
@@ -284,6 +304,12 @@ let PostMenuItems = ({
feedContext: postFeedContext, feedContext: postFeedContext,
reqId: postReqId, reqId: postReqId,
}) })
logger.metric('post:showLess', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
if (onShowLess) { if (onShowLess) {
onShowLess({ onShowLess({
item: postUri, item: postUri,
@@ -29,6 +29,7 @@ let PostMenuButton = ({
threadgateRecord, threadgateRecord,
onShowLess, onShowLess,
hitSlop, hitSlop,
logContext,
}: { }: {
testID: string testID: string
post: Shadow<AppBskyFeedDefs.PostView> post: Shadow<AppBskyFeedDefs.PostView>
@@ -41,6 +42,7 @@ let PostMenuButton = ({
threadgateRecord?: AppBskyFeedThreadgate.Record threadgateRecord?: AppBskyFeedThreadgate.Record
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
hitSlop?: Insets hitSlop?: Insets
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
}): React.ReactNode => { }): React.ReactNode => {
const {_} = useLingui() const {_} = useLingui()
@@ -87,6 +89,7 @@ let PostMenuButton = ({
timestamp={timestamp} timestamp={timestamp}
threadgateRecord={threadgateRecord} threadgateRecord={threadgateRecord}
onShowLess={onShowLess} onShowLess={onShowLess}
logContext={logContext}
/> />
)} )}
</Menu.Root> </Menu.Root>
@@ -16,6 +16,7 @@ import {useGate} from '#/lib/statsig/statsig'
import {toShareUrl} from '#/lib/strings/url-helpers' import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger' import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/post-shadow' import {type Shadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {EventStopper} from '#/view/com/util/EventStopper' import {EventStopper} from '#/view/com/util/EventStopper'
import {native} from '#/alf' import {native} from '#/alf'
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
@@ -35,6 +36,7 @@ let ShareMenuButton = ({
threadgateRecord, threadgateRecord,
onShare, onShare,
hitSlop, hitSlop,
logContext,
}: { }: {
testID: string testID: string
post: Shadow<AppBskyFeedDefs.PostView> post: Shadow<AppBskyFeedDefs.PostView>
@@ -45,9 +47,11 @@ let ShareMenuButton = ({
threadgateRecord?: AppBskyFeedThreadgate.Record threadgateRecord?: AppBskyFeedThreadgate.Record
onShare: () => void onShare: () => void
hitSlop?: Insets hitSlop?: Insets
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
}): React.ReactNode => { }): React.ReactNode => {
const {_} = useLingui() const {_} = useLingui()
const gate = useGate() const gate = useGate()
const {feedDescriptor} = useFeedFeedbackContext()
const ShareIcon = gate('alt_share_icon') const ShareIcon = gate('alt_share_icon')
? ArrowShareRightIcon ? ArrowShareRightIcon
@@ -65,13 +69,27 @@ let ShareMenuButton = ({
setTimeout(menuControl.open) setTimeout(menuControl.open)
logger.metric( logger.metric(
'share:open', 'post:share',
{context: big ? 'thread' : 'feed'}, {
uri: post.uri,
authorDid: post.author.did,
logContext,
feedDescriptor,
postContext: big ? 'thread' : 'feed',
},
{statsig: true}, {statsig: true},
) )
}, },
}), }),
[menuControl, setHasBeenOpen, big], [
menuControl,
setHasBeenOpen,
big,
logContext,
feedDescriptor,
post.uri,
post.author.did,
],
) )
const onNativeLongPress = () => { const onNativeLongPress = () => {
+19 -1
View File
@@ -13,6 +13,7 @@ import {CountWheel} from '#/lib/custom-animations/CountWheel'
import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/types' import {type Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useFeedFeedbackContext} from '#/state/feed-feedback'
import { import {
@@ -174,6 +175,12 @@ let PostControls = ({
feedContext, feedContext,
reqId, reqId,
}) })
logger.metric('post:clickQuotePost', {
uri: post.uri,
authorDid: post.author.did,
logContext,
feedDescriptor,
})
openComposer({ openComposer({
quote: post, quote: post,
onPost: onPostReply, onPost: onPostReply,
@@ -217,7 +224,16 @@ let PostControls = ({
testID="replyBtn" testID="replyBtn"
onPress={ onPress={
!replyDisabled !replyDisabled
? () => requireAuth(() => onPressReply()) ? () =>
requireAuth(() => {
logger.metric('post:clickReply', {
uri: post.uri,
authorDid: post.author.did,
logContext,
feedDescriptor,
})
onPressReply()
})
: undefined : undefined
} }
label={_( label={_(
@@ -315,6 +331,7 @@ let PostControls = ({
left: secondaryControlSpacingStyles.gap / 2, left: secondaryControlSpacingStyles.gap / 2,
right: secondaryControlSpacingStyles.gap / 2, right: secondaryControlSpacingStyles.gap / 2,
}} }}
logContext={logContext}
/> />
<PostMenuButton <PostMenuButton
testID="postDropdownBtn" testID="postDropdownBtn"
@@ -330,6 +347,7 @@ let PostControls = ({
hitSlop={{ hitSlop={{
left: secondaryControlSpacingStyles.gap / 2, left: secondaryControlSpacingStyles.gap / 2,
}} }}
logContext={logContext}
/> />
</View> </View>
</View> </View>
+12 -7
View File
@@ -31,8 +31,8 @@ import {
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {boostInterests, InterestTabs} from '#/components/InterestTabs' import {boostInterests, InterestTabs} from '#/components/InterestTabs'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
@@ -60,10 +60,16 @@ type Item =
key: string key: string
} }
export function FollowDialog({guide}: {guide: Follow10ProgressGuide}) { export function FollowDialog({
guide,
showArrow,
}: {
guide: Follow10ProgressGuide
showArrow?: boolean
}) {
const {_} = useLingui() const {_} = useLingui()
const control = Dialog.useDialogControl() const control = Dialog.useDialogControl()
const {gtMobile} = useBreakpoints() const {gtPhone} = useBreakpoints()
const {height: minHeight} = useWindowDimensions() const {height: minHeight} = useWindowDimensions()
return ( return (
@@ -74,13 +80,12 @@ export function FollowDialog({guide}: {guide: Follow10ProgressGuide}) {
control.open() control.open()
logEvent('progressGuide:followDialog:open', {}) logEvent('progressGuide:followDialog:open', {})
}} }}
size={gtMobile ? 'small' : 'large'} size={gtPhone ? 'small' : 'large'}
color="primary" color="primary">
variant="solid">
<ButtonIcon icon={PersonGroupIcon} />
<ButtonText> <ButtonText>
<Trans>Find people to follow</Trans> <Trans>Find people to follow</Trans>
</ButtonText> </ButtonText>
{showArrow && <ButtonIcon icon={ArrowRightIcon} />}
</Button> </Button>
<Dialog.Outer control={control} nativeOptions={{minHeight}}> <Dialog.Outer control={control} nativeOptions={{minHeight}}>
<Dialog.Handle /> <Dialog.Handle />
+124 -21
View File
@@ -2,37 +2,62 @@ import {type StyleProp, View, type ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session'
import { import {
useProgressGuide, useProgressGuide,
useProgressGuideControls, useProgressGuideControls,
} from '#/state/shell/progress-guide' } from '#/state/shell/progress-guide'
import {atoms as a, useTheme} from '#/alf' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import {Person_Stroke2_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {FollowDialog} from './FollowDialog' import {FollowDialog} from './FollowDialog'
import {ProgressGuideTask} from './Task' import {ProgressGuideTask} from './Task'
const TOTAL_AVATARS = 10
export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) { export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const {gtPhone} = useBreakpoints()
const {rightNavVisible} = useLayoutBreakpoints()
const {currentAccount} = useSession()
const followProgressGuide = useProgressGuide('follow-10') const followProgressGuide = useProgressGuide('follow-10')
const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7') const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7')
const guide = followProgressGuide || followAndLikeProgressGuide const guide = followProgressGuide || followAndLikeProgressGuide
const {endProgressGuide} = useProgressGuideControls() const {endProgressGuide} = useProgressGuideControls()
const {data: follows} = useProfileFollowsQuery(currentAccount?.did, {
limit: TOTAL_AVATARS,
})
const actualFollowsCount = follows?.pages?.[0]?.follows?.length ?? 0
// Hide if user already follows 10+ people
if (guide?.guide === 'follow-10' && actualFollowsCount >= TOTAL_AVATARS) {
return null
}
// Inline layout when left nav visible but no right sidebar (800-1100px)
const inlineLayout = gtPhone && !rightNavVisible
if (guide) { if (guide) {
return ( return (
<View style={[a.flex_col, a.gap_md, style]}> <View
style={[
a.flex_col,
a.gap_md,
a.rounded_md,
t.atoms.bg_contrast_25,
a.p_lg,
style,
]}>
<View style={[a.flex_row, a.align_center, a.justify_between]}> <View style={[a.flex_row, a.align_center, a.justify_between]}>
<Text <Text style={[t.atoms.text, a.font_semi_bold, a.text_md]}>
style={[ <Trans>Follow 10 people to get started</Trans>
t.atoms.text_contrast_medium,
a.font_semi_bold,
a.text_sm,
{textTransform: 'uppercase'},
]}>
<Trans>Getting started</Trans>
</Text> </Text>
<Button <Button
variant="ghost" variant="ghost"
@@ -40,20 +65,28 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
color="secondary" color="secondary"
shape="round" shape="round"
label={_(msg`Dismiss getting started guide`)} label={_(msg`Dismiss getting started guide`)}
onPress={endProgressGuide}> onPress={endProgressGuide}
<ButtonIcon icon={Times} size="sm" /> style={[a.bg_transparent, {marginTop: -6, marginRight: -6}]}>
<ButtonIcon icon={Times} size="xs" />
</Button> </Button>
</View> </View>
{guide.guide === 'follow-10' && ( {guide.guide === 'follow-10' && (
<> <View
<ProgressGuideTask style={[
current={guide.numFollows + 1} inlineLayout
total={10 + 1} ? [
title={_(msg`Follow 10 accounts`)} a.flex_row,
subtitle={_(msg`Bluesky is better with friends!`)} a.flex_wrap,
/> a.align_center,
<FollowDialog guide={guide} /> a.justify_between,
</> a.gap_sm,
]
: a.flex_col,
!inlineLayout && a.gap_md,
]}>
<StackedAvatars follows={follows?.pages?.[0]?.follows} />
<FollowDialog guide={guide} showArrow={inlineLayout} />
</View>
)} )}
{guide.guide === 'like-10-and-follow-7' && ( {guide.guide === 'like-10-and-follow-7' && (
<> <>
@@ -76,3 +109,73 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
} }
return null return null
} }
function StackedAvatars({follows}: {follows?: bsky.profile.AnyProfileView[]}) {
const t = useTheme()
const {centerColumnOffset} = useLayoutBreakpoints()
// Smaller avatars for narrower viewport
const avatarSize = centerColumnOffset ? 30 : 37
const overlap = centerColumnOffset ? 9 : 11
const iconSize = centerColumnOffset ? 14 : 18
// Use actual follows count, not the guide's event counter
const followedAvatars = follows?.slice(0, TOTAL_AVATARS) ?? []
const remainingSlots = TOTAL_AVATARS - followedAvatars.length
// Total width calculation: first avatar + (remaining * visible portion)
const totalWidth = avatarSize + (TOTAL_AVATARS - 1) * (avatarSize - overlap)
return (
<View style={[a.flex_row, a.self_start, {width: totalWidth}]}>
{/* Show followed user avatars */}
{followedAvatars.map((follow, i) => (
<View
key={follow.did}
style={[
a.rounded_full,
{
marginLeft: i === 0 ? 0 : -overlap,
zIndex: TOTAL_AVATARS - i,
borderWidth: 2,
borderColor: t.atoms.bg_contrast_25.backgroundColor,
},
]}>
<UserAvatar
type="user"
size={avatarSize - 4}
avatar={follow.avatar}
/>
</View>
))}
{/* Show placeholder avatars for remaining slots */}
{Array(remainingSlots)
.fill(0)
.map((_, i) => (
<View
key={`placeholder-${i}`}
style={[
a.align_center,
a.justify_center,
a.rounded_full,
t.atoms.bg_contrast_100,
{
width: avatarSize,
height: avatarSize,
marginLeft:
followedAvatars.length === 0 && i === 0 ? 0 : -overlap,
zIndex: TOTAL_AVATARS - followedAvatars.length - i,
borderWidth: 2,
borderColor: t.atoms.bg_contrast_25.backgroundColor,
},
]}>
<PersonIcon
width={iconSize}
height={iconSize}
fill={t.atoms.text_contrast_low.color}
/>
</View>
))}
</View>
)
}
+2 -2
View File
@@ -31,11 +31,11 @@ export function ProgressGuideTask({
size={20} size={20}
thickness={3} thickness={3}
borderWidth={0} borderWidth={0}
unfilledColor={t.palette.contrast_50} unfilledColor={t.palette.contrast_100}
/> />
)} )}
<View style={[a.flex_col, a.gap_2xs, subtitle && {marginTop: -2}]}> <View style={[a.flex_col, a.gap_xs, subtitle && {marginTop: -2}]}>
<Text <Text
style={[ style={[
a.text_sm, a.text_sm,
+5 -1
View File
@@ -11,6 +11,9 @@ import {RichTextTag} from '#/components/RichTextTag'
import {Text, type TextProps} from '#/components/Typography' import {Text, type TextProps} from '#/components/Typography'
const WORD_WRAP = {wordWrap: 1} const WORD_WRAP = {wordWrap: 1}
// lifted from facet detection in `RichText` impl, _without_ `gm` flags
const URL_REGEX =
/(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/i
export type RichTextProps = TextStyleProp & export type RichTextProps = TextStyleProp &
Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & { Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
@@ -115,7 +118,8 @@ export function RichText({
</ProfileHoverCard>, </ProfileHoverCard>,
) )
} else if (link && AppBskyRichtextFacet.validateLink(link).success) { } else if (link && AppBskyRichtextFacet.validateLink(link).success) {
if (disableLinks) { const isValidLink = URL_REGEX.test(link.uri)
if (!isValidLink || disableLinks) {
els.push(toShortUrl(segment.text)) els.push(toShortUrl(segment.text))
} else { } else {
els.push( els.push(
+10 -9
View File
@@ -20,8 +20,12 @@ export function TrendingTopic({
topic: raw, topic: raw,
size, size,
style, style,
}: {topic: TrendingTopic; size?: 'large' | 'small'} & ViewStyleProp) { hovered,
const t = useTheme() }: {
topic: TrendingTopic
size?: 'large' | 'small'
hovered?: boolean
} & ViewStyleProp) {
const topic = useTopic(raw) const topic = useTopic(raw)
const isSmall = size === 'small' const isSmall = size === 'small'
@@ -33,18 +37,14 @@ export function TrendingTopic({
style={[ style={[
a.flex_row, a.flex_row,
a.align_center, a.align_center,
a.rounded_full,
a.border,
t.atoms.border_contrast_medium,
t.atoms.bg,
isSmall isSmall
? [ ? [
{ {
paddingVertical: 5, paddingVertical: 2,
paddingHorizontal: 10, paddingHorizontal: 4,
}, },
] ]
: [a.py_sm, a.px_md], : [a.py_xs, a.px_sm],
hasIcon && {gap: 6}, hasIcon && {gap: 6},
style, style,
]}> ]}>
@@ -93,6 +93,7 @@ export function TrendingTopic({
a.font_semi_bold, a.font_semi_bold,
a.leading_tight, a.leading_tight,
isSmall ? [a.text_sm] : [a.text_md, {paddingBottom: 1}], isSmall ? [a.text_sm] : [a.text_md, {paddingBottom: 1}],
hovered && {textDecorationLine: 'underline'},
]} ]}
numberOfLines={1}> numberOfLines={1}>
{topic.displayName} {topic.displayName}
+2 -3
View File
@@ -41,7 +41,7 @@ export function Inner() {
}, [setTrendingDisabled]) }, [setTrendingDisabled])
return error || noTopics ? null : ( 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> <BlockDrawerGesture>
<ScrollView <ScrollView
horizontal horizontal
@@ -99,10 +99,9 @@ export function Inner() {
<View style={[a.py_lg]}> <View style={[a.py_lg]}>
<Text <Text
style={[ style={[
t.atoms.text, t.atoms.text_contrast_medium,
a.text_sm, a.text_sm,
a.font_semi_bold, a.font_semi_bold,
{opacity: 0.7}, // NOTE: we use opacity 0.7 instead of a color to match the color of the home pager tab bar
]}> ]}>
{topic.topic} {topic.topic}
</Text> </Text>
+5 -2
View File
@@ -46,6 +46,9 @@ export function DialogInner({
if (!agent.session?.did) { if (!agent.session?.did) {
throw new Error('Not logged in') throw new Error('Not logged in')
} }
if (!status.uri || !status.cid) {
throw new Error('Status is missing uri or cid')
}
if (__DEV__) { if (__DEV__) {
logger.info('Submitting go live appeal', { logger.info('Submitting go live appeal', {
@@ -57,8 +60,8 @@ export function DialogInner({
reasonType: ToolsOzoneReportDefs.REASONAPPEAL, reasonType: ToolsOzoneReportDefs.REASONAPPEAL,
subject: { subject: {
$type: 'com.atproto.repo.strongRef', $type: 'com.atproto.repo.strongRef',
uri: status.uri!, uri: status.uri,
cid: status.cid!, cid: status.cid,
}, },
reason: details, reason: details,
}, },
@@ -3,6 +3,8 @@ import {
ToolsOzoneReportDefs as OzoneReportDefs, ToolsOzoneReportDefs as OzoneReportDefs,
} from '@atproto/api' } from '@atproto/api'
import {type ParsedReportSubject} from '#/components/moderation/ReportDialog/types'
export const DMCA_LINK = 'https://bsky.social/about/support/copyright' export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
export const SUPPORT_PAGE = 'https://bsky.social/about/support' export const SUPPORT_PAGE = 'https://bsky.social/about/support'
@@ -112,3 +114,10 @@ export const BSKY_LABELER_ONLY_REPORT_REASONS: Set<OzoneReportDefs.ReasonType> =
OzoneReportDefs.REASONCHILDSAFETYOTHER, OzoneReportDefs.REASONCHILDSAFETYOTHER,
OzoneReportDefs.REASONVIOLENCEEXTREMISTCONTENT, OzoneReportDefs.REASONVIOLENCEEXTREMISTCONTENT,
]) ])
/**
* Set of _parsed_ subject types that should only be sent to Bluesky's
* moderation service.
*/
export const BSKY_LABELER_ONLY_SUBJECT_TYPES: Set<ParsedReportSubject['type']> =
new Set(['convoMessage', 'status'])
@@ -32,6 +32,7 @@ import {Text} from '#/components/Typography'
import {useSubmitReportMutation} from './action' import {useSubmitReportMutation} from './action'
import { import {
BSKY_LABELER_ONLY_REPORT_REASONS, BSKY_LABELER_ONLY_REPORT_REASONS,
BSKY_LABELER_ONLY_SUBJECT_TYPES,
NEW_TO_OLD_REASONS_MAP, NEW_TO_OLD_REASONS_MAP,
SUPPORT_PAGE, SUPPORT_PAGE,
} from './const' } from './const'
@@ -127,8 +128,10 @@ function Inner(props: ReportDialogProps) {
const isBskyOnlyReason = state?.selectedOption?.reason const isBskyOnlyReason = state?.selectedOption?.reason
? BSKY_LABELER_ONLY_REPORT_REASONS.has(state.selectedOption.reason) ? BSKY_LABELER_ONLY_REPORT_REASONS.has(state.selectedOption.reason)
: false : false
// some subjects (chats) only go to Bluesky // some subjects ONLY go to Bluesky
const isBskyOnlySubject = props.subject.type === 'convoMessage' const isBskyOnlySubject = BSKY_LABELER_ONLY_SUBJECT_TYPES.has(
props.subject.type,
)
/** /**
* Labelers that support this `subject` and its NSID collection * Labelers that support this `subject` and its NSID collection
@@ -835,12 +838,7 @@ function LabelerCard({
{title} {title}
</Text> </Text>
<Text <Text
style={[ style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
a.text_sm,
,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>By {sanitizeHandle(labeler.creator.handle, '@')}</Trans> <Trans>By {sanitizeHandle(labeler.creator.handle, '@')}</Trans>
</Text> </Text>
</View> </View>
@@ -34,10 +34,11 @@ export function parseReportSubject(
nsid: 'app.bsky.actor.profile', nsid: 'app.bsky.actor.profile',
} }
} else if (AppBskyActorDefs.isStatusView(subject)) { } else if (AppBskyActorDefs.isStatusView(subject)) {
if (!subject.uri || !subject.cid) return
return { return {
type: 'status', type: 'status',
uri: subject.uri!, uri: subject.uri,
cid: subject.cid!, cid: subject.cid,
nsid: 'app.bsky.actor.status', nsid: 'app.bsky.actor.status',
} }
} else if (AppBskyGraphDefs.isListView(subject)) { } else if (AppBskyGraphDefs.isListView(subject)) {
+15 -5
View File
@@ -108,8 +108,18 @@ export const GCP_PROJECT_ID: number =
* URLs for the app config web worker. Can be a * URLs for the app config web worker. Can be a
* locally running server, see `env.example` for more. * locally running server, see `env.example` for more.
*/ */
export const BAPP_CONFIG_DEV_URL = process.env.BAPP_CONFIG_DEV_URL export const GEOLOCATION_DEV_URL = process.env.GEOLOCATION_DEV_URL
export const BAPP_CONFIG_PROD_URL = `https://ip.bsky.app` export const GEOLOCATION_PROD_URL = `https://ip.bsky.app`
export const BAPP_CONFIG_URL = IS_DEV export const GEOLOCATION_URL = IS_DEV
? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_PROD_URL) ? (GEOLOCATION_DEV_URL ?? GEOLOCATION_PROD_URL)
: BAPP_CONFIG_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'
+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' 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. * Default geolocation config.
+9 -4
View File
@@ -7,7 +7,7 @@ import {
import {isAfter, parseISO} from 'date-fns' import {isAfter, parseISO} from 'date-fns'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {type LiveNowConfig, useLiveNowConfig} from '#/state/service-config' import {useLiveNowConfig} from '#/state/service-config'
import {useTickEveryMinute} from '#/state/shell' import {useTickEveryMinute} from '#/state/shell'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
@@ -20,7 +20,7 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
tick! // revalidate every minute tick! // revalidate every minute
if (shadowed && 'status' in shadowed && shadowed.status) { if (shadowed && 'status' in shadowed && shadowed.status) {
const isValid = validateStatus(shadowed.status, config) const isValid = validateStatus(shadowed.did, shadowed.status, config)
const isDisabled = shadowed.status.isDisabled || false const isDisabled = shadowed.status.isDisabled || false
const isActive = isStatusStillActive(shadowed.status.expiresAt) const isActive = isStatusStillActive(shadowed.status.expiresAt)
if (isValid && !isDisabled && isActive) { if (isValid && !isDisabled && isActive) {
@@ -65,14 +65,19 @@ export function isStatusStillActive(timeStr: string | undefined) {
} }
export function validateStatus( export function validateStatus(
did: string,
status: AppBskyActorDefs.StatusView, status: AppBskyActorDefs.StatusView,
config: LiveNowConfig, config: {did: string; domains: string[]}[],
) { ) {
if (status.status !== 'app.bsky.actor.status#live') return false if (status.status !== 'app.bsky.actor.status#live') return false
const sources = config.find(cfg => cfg.did === did)
if (!sources) {
return false
}
try { try {
if (AppBskyEmbedExternal.isView(status.embed)) { if (AppBskyEmbedExternal.isView(status.embed)) {
const url = new URL(status.embed.external.uri) const url = new URL(status.embed.external.uri)
return config.allowedDomains.includes(url.hostname) return sources.domains.includes(url.hostname)
} else { } else {
return false 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 {AppState, type AppStateStatus} from 'react-native'
import AsyncStorage from '@react-native-async-storage/async-storage' import AsyncStorage from '@react-native-async-storage/async-storage'
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
@@ -9,9 +9,15 @@ import {
} from '@tanstack/react-query-persist-client' } from '@tanstack/react-query-persist-client'
import type React from 'react' import type React from 'react'
import {isNative} from '#/platform/detection' import {isNative, isWeb} from '#/platform/detection'
import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events' 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 // any query keys in this array will be persisted to AsyncStorage
export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info' export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info'
const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot] const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot]
@@ -180,6 +186,11 @@ function QueryProviderInner({
dehydrateOptions, dehydrateOptions,
} }
}) })
useEffect(() => {
if (isWeb) {
window.__TANSTACK_QUERY_CLIENT__ = queryClient
}
}, [queryClient])
return ( return (
<PersistQueryClientProvider <PersistQueryClientProvider
client={queryClient} client={queryClient}
+1 -1
View File
@@ -7,7 +7,7 @@ export type Gate =
| 'disable_settings_find_contacts' | 'disable_settings_find_contacts'
| 'explore_show_suggested_feeds' | 'explore_show_suggested_feeds'
| 'feed_reply_button_open_thread' | 'feed_reply_button_open_thread'
| 'live_now_beta' | 'is_bsky_team_member' // special, do not remove
| 'old_postonboarding' | 'old_postonboarding'
| 'onboarding_add_video_feed' | 'onboarding_add_video_feed'
| 'onboarding_suggested_starterpacks' | '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 {isNative} from '#/platform/detection'
import {ENV} from '#/env' import {ENV} from '#/env'
export {type MetricEvents as Metrics} from '#/logger/metrics'
const TRANSPORTS: Transport[] = (function configureTransports() { const TRANSPORTS: Transport[] = (function configureTransports() {
switch (ENV) { switch (ENV) {
case 'production': { case 'production': {
+109 -9
View File
@@ -1,5 +1,6 @@
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
import {type FeedDescriptor} from '#/state/queries/post-feed' import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
export type MetricEvents = { export type MetricEvents = {
// App events // App events
@@ -176,13 +177,19 @@ export type MetricEvents = {
'feed:suggestion:press': { 'feed:suggestion:press': {
feedUrl: string feedUrl: string
} }
'feed:showMore': { 'post:showMore': {
feed: string uri: string
feedContext: string authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
} }
'feed:showLess': { 'post:showLess': {
feed: string uri: string
feedContext: string authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
} }
'feed:clickthrough': { 'feed:clickthrough': {
feed: string feed: string
@@ -238,6 +245,8 @@ export type MetricEvents = {
isReply: boolean isReply: boolean
} }
'post:like': { 'post:like': {
uri: string
authorDid: string
doesLikerFollowPoster: boolean | undefined doesLikerFollowPoster: boolean | undefined
doesPosterFollowLiker: boolean | undefined doesPosterFollowLiker: boolean | undefined
likerClout: number | undefined likerClout: number | undefined
@@ -246,26 +255,87 @@ export type MetricEvents = {
feedDescriptor?: string feedDescriptor?: string
} }
'post:repost': { 'post:repost': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string feedDescriptor?: string
} }
'post:unlike': { 'post:unlike': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string feedDescriptor?: string
} }
'post:unrepost': { 'post:unrepost': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string feedDescriptor?: string
} }
'post:mute': {} 'post:mute': {
'post:unmute': {} uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'post:unmute': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'post:pin': {} 'post:pin': {}
'post:unpin': {} 'post:unpin': {}
'post:bookmark': { 'post:bookmark': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
} }
'post:unbookmark': { 'post:unbookmark': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'post:clickReply': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'post:clickQuotePost': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'post:clickthroughAuthor': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'post:clickthroughItem': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'post:clickthroughEmbed': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
} }
'post:view': { 'post:view': {
uri: string uri: string
@@ -565,7 +635,14 @@ export type MetricEvents = {
'live:view:profile': {subject: string} 'live:view:profile': {subject: string}
'live:view:post': {subject: string; feed?: string} 'live:view:post': {subject: string; feed?: string}
'share:open': {context: 'feed' | 'thread'} 'post:share': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
postContext: 'feed' | 'thread'
position?: number
}
'share:press:copyLink': {} 'share:press:copyLink': {}
'share:press:nativeShare': {} 'share:press:nativeShare': {}
'share:press:openDmSearch': {} 'share:press:openDmSearch': {}
@@ -720,4 +797,27 @@ export type MetricEvents = {
} }
// user pressed the remove all data button // user pressed the remove all data button
'contacts:settings:removeData': {} '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
}
} }
@@ -281,6 +281,12 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
]) ])
const onOpenAuthor = () => { const onOpenAuthor = () => {
logger.metric('post:clickthroughAuthor', {
uri: post.uri,
authorDid: post.author.did,
logContext: 'PostThreadItem',
feedDescriptor: feedFeedback.feedDescriptor,
})
if (postSource) { if (postSource) {
feedFeedback.sendInteraction({ feedFeedback.sendInteraction({
item: post.uri, item: post.uri,
@@ -292,6 +298,12 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
} }
const onOpenEmbed = () => { const onOpenEmbed = () => {
logger.metric('post:clickthroughEmbed', {
uri: post.uri,
authorDid: post.author.did,
logContext: 'PostThreadItem',
feedDescriptor: feedFeedback.feedDescriptor,
})
if (postSource) { if (postSource) {
feedFeedback.sendInteraction({ feedFeedback.sendInteraction({
item: post.uri, item: post.uri,
+10
View File
@@ -68,6 +68,7 @@ import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import {SubtleHover} from '#/components/SubtleHover' import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {ExploreScreenLiveEventFeedsBanner} from '#/features/liveEvents/components/ExploreScreenLiveEventFeedsBanner'
import * as ModuleHeader from './components/ModuleHeader' import * as ModuleHeader from './components/ModuleHeader'
import { import {
SuggestedAccountsTabBar, SuggestedAccountsTabBar,
@@ -201,6 +202,10 @@ type ExploreScreenItems =
type: 'interests-card' type: 'interests-card'
key: 'interests-card' key: 'interests-card'
} }
| {
type: 'liveEventFeedsBanner'
key: string
}
export function Explore({ export function Explore({
focusSearchInput, focusSearchInput,
@@ -684,6 +689,8 @@ export function Explore({
i.push(topBorder) i.push(topBorder)
i.push(...interestsNuxModule) i.push(...interestsNuxModule)
i.push({type: 'liveEventFeedsBanner', key: 'liveEventFeedsBanner'})
if (useFullExperience) { if (useFullExperience) {
i.push(trendingTopicsModule) i.push(trendingTopicsModule)
i.push(...suggestedFeedsModule) i.push(...suggestedFeedsModule)
@@ -998,6 +1005,9 @@ export function Explore({
case 'interests-card': { case 'interests-card': {
return <ExploreInterestsCard /> 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 {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending'
import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {LiveEventFeedsSettingsToggle} from '#/features/liveEvents/components/LiveEventFeedsSettingsToggle'
type Props = NativeStackScreenProps< type Props = NativeStackScreenProps<
CommonNavigatorParams, CommonNavigatorParams,
@@ -124,7 +125,7 @@ export function ContentAndMediaSettingsScreen({}: Props) {
<Toggle.Platform /> <Toggle.Platform />
</SettingsList.Item> </SettingsList.Item>
</Toggle.Item> </Toggle.Item>
{trendingEnabled && ( {trendingEnabled ? (
<> <>
<SettingsList.Divider /> <SettingsList.Divider />
<Toggle.Item <Toggle.Item
@@ -148,6 +149,7 @@ export function ContentAndMediaSettingsScreen({}: Props) {
<Toggle.Platform /> <Toggle.Platform />
</SettingsList.Item> </SettingsList.Item>
</Toggle.Item> </Toggle.Item>
<LiveEventFeedsSettingsToggle />
<Toggle.Item <Toggle.Item
name="show_trending_videos" name="show_trending_videos"
label={_(msg`Enable trending videos in your Discover feed`)} label={_(msg`Enable trending videos in your Discover feed`)}
@@ -170,6 +172,11 @@ export function ContentAndMediaSettingsScreen({}: Props) {
</SettingsList.Item> </SettingsList.Item>
</Toggle.Item> </Toggle.Item>
</> </>
) : (
<>
<SettingsList.Divider />
<LiveEventFeedsSettingsToggle />
</>
)} )}
</SettingsList.Container> </SettingsList.Container>
</Layout.Content> </Layout.Content>
+1 -20
View File
@@ -137,7 +137,6 @@ export function useFeedFeedback(
sendOrAggregateInteractionsForStats( sendOrAggregateInteractionsForStats(
aggregatedStats.current, aggregatedStats.current,
interactionsToSend, interactionsToSend,
feed?.feedDescriptor ?? 'unknown',
) )
throttledFlushAggregatedStats() throttledFlushAggregatedStats()
logger.debug('flushed') logger.debug('flushed')
@@ -274,28 +273,10 @@ function createAggregatedStats(): AggregatedStats {
function sendOrAggregateInteractionsForStats( function sendOrAggregateInteractionsForStats(
stats: AggregatedStats, stats: AggregatedStats,
interactions: AppBskyFeedDefs.Interaction[], interactions: AppBskyFeedDefs.Interaction[],
feed: string,
) { ) {
for (let interaction of interactions) { for (let interaction of interactions) {
switch (interaction.event) { switch (interaction.event) {
// Pressing "Show more" / "Show less" is relatively uncommon so we won't aggregate them. // The events are aggregated and sent later in batches.
// This lets us send the feed context together with them.
case 'app.bsky.feed.defs#requestLess': {
logger.metric('feed:showLess', {
feed,
feedContext: interaction.feedContext ?? '',
})
break
}
case 'app.bsky.feed.defs#requestMore': {
logger.metric('feed:showMore', {
feed,
feedContext: interaction.feedContext ?? '',
})
break
}
// The rest of the events are aggregated and sent later in batches.
case 'app.bsky.feed.defs#clickthroughAuthor': case 'app.bsky.feed.defs#clickthroughAuthor':
case 'app.bsky.feed.defs#clickthroughEmbed': case 'app.bsky.feed.defs#clickthroughEmbed':
case 'app.bsky.feed.defs#clickthroughItem': case 'app.bsky.feed.defs#clickthroughItem':
+32 -10
View File
@@ -111,7 +111,7 @@ export function usePostLikeMutationQueue(
const postCid = post.cid const postCid = post.cid
const initialLikeUri = post.viewer?.like const initialLikeUri = post.viewer?.like
const likeMutation = usePostLikeMutation(feedDescriptor, logContext, post) const likeMutation = usePostLikeMutation(feedDescriptor, logContext, post)
const unlikeMutation = usePostUnlikeMutation(feedDescriptor, logContext) const unlikeMutation = usePostUnlikeMutation(feedDescriptor, logContext, post)
const queueToggle = useToggleMutationQueue({ const queueToggle = useToggleMutationQueue({
initialState: initialLikeUri, initialState: initialLikeUri,
@@ -182,6 +182,8 @@ function usePostLikeMutation(
ownProfile = findProfileQueryData(queryClient, currentAccount.did) ownProfile = findProfileQueryData(queryClient, currentAccount.did)
} }
logger.metric('post:like', { logger.metric('post:like', {
uri,
authorDid: postAuthor.did,
logContext, logContext,
doesPosterFollowLiker: postAuthor.viewer doesPosterFollowLiker: postAuthor.viewer
? Boolean(postAuthor.viewer.followedBy) ? Boolean(postAuthor.viewer.followedBy)
@@ -206,11 +208,17 @@ function usePostLikeMutation(
function usePostUnlikeMutation( function usePostUnlikeMutation(
feedDescriptor: string | undefined, feedDescriptor: string | undefined,
logContext: LogEvents['post:unlike']['logContext'], logContext: LogEvents['post:unlike']['logContext'],
post: Shadow<AppBskyFeedDefs.PostView>,
) { ) {
const agent = useAgent() const agent = useAgent()
return useMutation<void, Error, {postUri: string; likeUri: string}>({ return useMutation<void, Error, {postUri: string; likeUri: string}>({
mutationFn: ({likeUri}) => { mutationFn: ({postUri, likeUri}) => {
logger.metric('post:unlike', {logContext, feedDescriptor}) logger.metric('post:unlike', {
uri: postUri,
authorDid: post.author.did,
logContext,
feedDescriptor,
})
return agent.deleteLike(likeUri) return agent.deleteLike(likeUri)
}, },
}) })
@@ -227,8 +235,12 @@ export function usePostRepostMutationQueue(
const postUri = post.uri const postUri = post.uri
const postCid = post.cid const postCid = post.cid
const initialRepostUri = post.viewer?.repost const initialRepostUri = post.viewer?.repost
const repostMutation = usePostRepostMutation(feedDescriptor, logContext) const repostMutation = usePostRepostMutation(feedDescriptor, logContext, post)
const unrepostMutation = usePostUnrepostMutation(feedDescriptor, logContext) const unrepostMutation = usePostUnrepostMutation(
feedDescriptor,
logContext,
post,
)
const queueToggle = useToggleMutationQueue({ const queueToggle = useToggleMutationQueue({
initialState: initialRepostUri, initialState: initialRepostUri,
@@ -280,6 +292,7 @@ export function usePostRepostMutationQueue(
function usePostRepostMutation( function usePostRepostMutation(
feedDescriptor: string | undefined, feedDescriptor: string | undefined,
logContext: LogEvents['post:repost']['logContext'], logContext: LogEvents['post:repost']['logContext'],
post: Shadow<AppBskyFeedDefs.PostView>,
) { ) {
const agent = useAgent() const agent = useAgent()
return useMutation< 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 {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}) => { 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) return agent.repost(uri, cid, via)
}, },
}) })
@@ -297,11 +315,17 @@ function usePostRepostMutation(
function usePostUnrepostMutation( function usePostUnrepostMutation(
feedDescriptor: string | undefined, feedDescriptor: string | undefined,
logContext: LogEvents['post:unrepost']['logContext'], logContext: LogEvents['post:unrepost']['logContext'],
post: Shadow<AppBskyFeedDefs.PostView>,
) { ) {
const agent = useAgent() const agent = useAgent()
return useMutation<void, Error, {postUri: string; repostUri: string}>({ return useMutation<void, Error, {postUri: string; repostUri: string}>({
mutationFn: ({repostUri}) => { mutationFn: ({postUri, repostUri}) => {
logger.metric('post:unrepost', {logContext, feedDescriptor}) logger.metric('post:unrepost', {
uri: postUri,
authorDid: post.author.did,
logContext,
feedDescriptor,
})
return agent.deleteRepost(repostUri) return agent.deleteRepost(repostUri)
}, },
}) })
@@ -373,7 +397,6 @@ function useThreadMuteMutation() {
{uri: string} // the root post's uri {uri: string} // the root post's uri
>({ >({
mutationFn: ({uri}) => { mutationFn: ({uri}) => {
logger.metric('post:mute', {})
return agent.api.app.bsky.graph.muteThread({root: uri}) return agent.api.app.bsky.graph.muteThread({root: uri})
}, },
}) })
@@ -383,7 +406,6 @@ function useThreadUnmuteMutation() {
const agent = useAgent() const agent = useAgent()
return useMutation<{}, Error, {uri: string}>({ return useMutation<{}, Error, {uri: string}>({
mutationFn: ({uri}) => { mutationFn: ({uri}) => {
logger.metric('post:unmute', {})
return agent.api.app.bsky.graph.unmuteThread({root: uri}) return agent.api.app.bsky.graph.unmuteThread({root: uri})
}, },
}) })
+4
View File
@@ -45,4 +45,8 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
verificationPrefs: { verificationPrefs: {
hideBadges: false, hideBadges: false,
}, },
liveEventPreferences: {
hideAllFeeds: false,
hiddenFeedIds: [],
},
} }
+45
View File
@@ -4,12 +4,14 @@ import {
type AppBskyActorGetProfile, type AppBskyActorGetProfile,
type AppBskyActorGetProfiles, type AppBskyActorGetProfiles,
type AppBskyActorProfile, type AppBskyActorProfile,
type AppBskyGraphGetFollows,
AtUri, AtUri,
type BskyAgent, type BskyAgent,
type ComAtprotoRepoUploadBlob, type ComAtprotoRepoUploadBlob,
type Un$Typed, type Un$Typed,
} from '@atproto/api' } from '@atproto/api'
import { import {
type InfiniteData,
keepPreviousData, keepPreviousData,
type QueryClient, type QueryClient,
useMutation, useMutation,
@@ -26,6 +28,7 @@ import {type Shadow} from '#/state/cache/types'
import {type ImageMeta} from '#/state/gallery' import {type ImageMeta} from '#/state/gallery'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {resetProfilePostsQueries} from '#/state/queries/post-feed' import {resetProfilePostsQueries} from '#/state/queries/post-feed'
import {RQKEY as PROFILE_FOLLOWS_RQKEY} from '#/state/queries/profile-follows'
import { import {
unstableCacheProfileView, unstableCacheProfileView,
useUnstableProfileViewCache, useUnstableProfileViewCache,
@@ -247,6 +250,7 @@ export function useProfileFollowMutationQueue(
) { ) {
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {currentAccount} = useSession()
const did = profile.did const did = profile.did
const initialFollowingUri = profile.viewer?.following const initialFollowingUri = profile.viewer?.following
const followMutation = useProfileFollowMutation( const followMutation = useProfileFollowMutation(
@@ -283,6 +287,47 @@ export function useProfileFollowMutationQueue(
followingUri: finalFollowingUri, followingUri: finalFollowingUri,
}) })
// Optimistically update profile follows cache for avatar displays
if (currentAccount?.did) {
type FollowsQueryData =
InfiniteData<AppBskyGraphGetFollows.OutputSchema>
queryClient.setQueryData<FollowsQueryData>(
PROFILE_FOLLOWS_RQKEY(currentAccount.did),
old => {
if (!old?.pages?.[0]) return old
if (finalFollowingUri) {
// Add the followed profile to the beginning
const alreadyExists = old.pages[0].follows.some(
f => f.did === profile.did,
)
if (alreadyExists) return old
return {
...old,
pages: [
{
...old.pages[0],
follows: [
profile as AppBskyActorDefs.ProfileView,
...old.pages[0].follows,
],
},
...old.pages.slice(1),
],
}
} else {
// Remove the unfollowed profile
return {
...old,
pages: old.pages.map(page => ({
...page,
follows: page.follows.filter(f => f.did !== profile.did),
})),
}
}
},
)
}
if (finalFollowingUri) { if (finalFollowingUri) {
agent.app.bsky.graph agent.app.bsky.graph
.getSuggestedFollowsByActor({ .getSuggestedFollowsByActor({
-2
View File
@@ -148,8 +148,6 @@ export function ComposerPrompt() {
a.relative, a.relative,
a.flex_row, a.flex_row,
a.align_start, a.align_start,
a.border_t,
t.atoms.border_contrast_low,
{ {
paddingLeft: 18, paddingLeft: 18,
paddingRight: 15, paddingRight: 15,
+12 -10
View File
@@ -70,6 +70,7 @@ import {
} from '#/components/feeds/PostFeedVideoGridRow' } from '#/components/feeds/PostFeedVideoGridRow'
import {TrendingInterstitial} from '#/components/interstitials/Trending' import {TrendingInterstitial} from '#/components/interstitials/Trending'
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner'
import {ComposerPrompt} from '../feeds/ComposerPrompt' import {ComposerPrompt} from '../feeds/ComposerPrompt'
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
import {FeedShutdownMsg} from './FeedShutdownMsg' import {FeedShutdownMsg} from './FeedShutdownMsg'
@@ -155,6 +156,10 @@ type FeedRow =
type: 'composerPrompt' type: 'composerPrompt'
key: string key: string
} }
| {
type: 'liveEventFeedsAndTrendingBanner'
key: string
}
export function getItemsForFeedback(feedRow: FeedRow): { export function getItemsForFeedback(feedRow: FeedRow): {
item: FeedPostSliceItem item: FeedPostSliceItem
@@ -360,7 +365,7 @@ let PostFeed = ({
const showProgressIntersitial = const showProgressIntersitial =
(followProgressGuide || followAndLikeProgressGuide) && !rightNavVisible (followProgressGuide || followAndLikeProgressGuide) && !rightNavVisible
const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings() const {trendingVideoDisabled} = useTrendingSettings()
const ageAssuranceBannerState = useAgeAssuranceBannerState() const ageAssuranceBannerState = useAgeAssuranceBannerState()
const selectedFeed = useSelectedFeed() const selectedFeed = useSelectedFeed()
@@ -510,13 +515,10 @@ let PostFeed = ({
}) })
} }
} }
if (!rightNavVisible && !trendingDisabled) { arr.push({
arr.push({ type: 'liveEventFeedsAndTrendingBanner',
type: 'interstitialTrending', key: 'liveEventFeedsAndTrendingBanner-' + sliceIndex,
key: })
'interstitial2-' + sliceIndex + '-' + lastFetchedAt,
})
}
// Show composer prompt for Discover and Following feeds // Show composer prompt for Discover and Following feeds
if ( if (
hasSession && hasSession &&
@@ -672,9 +674,7 @@ let PostFeed = ({
feedTab, feedTab,
hasSession, hasSession,
showProgressIntersitial, showProgressIntersitial,
trendingDisabled,
trendingVideoDisabled, trendingVideoDisabled,
rightNavVisible,
gtMobile, gtMobile,
isVideoFeed, isVideoFeed,
areVideoFeedsEnabled, areVideoFeedsEnabled,
@@ -773,6 +773,8 @@ let PostFeed = ({
return <AgeAssuranceDismissibleFeedBanner /> return <AgeAssuranceDismissibleFeedBanner />
} else if (row.type === 'interstitialTrending') { } else if (row.type === 'interstitialTrending') {
return <TrendingInterstitial /> return <TrendingInterstitial />
} else if (row.type === 'liveEventFeedsAndTrendingBanner') {
return <DiscoverFeedLiveEventFeedsAndTrendingBanner />
} else if (row.type === 'composerPrompt') { } else if (row.type === 'composerPrompt') {
return <ComposerPrompt /> return <ComposerPrompt />
} else if (row.type === 'interstitialTrendingVideos') { } else if (row.type === 'interstitialTrendingVideos') {
+21 -1
View File
@@ -21,6 +21,7 @@ import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig' import {useGate} from '#/lib/statsig/statsig'
import {countLines} from '#/lib/strings/helpers' import {countLines} from '#/lib/strings/helpers'
import {logger} from '#/logger'
import { import {
POST_TOMBSTONE, POST_TOMBSTONE,
type Shadow, type Shadow,
@@ -173,7 +174,8 @@ let FeedItemInner = ({
const urip = new AtUri(post.uri) const urip = new AtUri(post.uri)
return [makeProfileLink(post.author, 'post', urip.rkey), urip.rkey] return [makeProfileLink(post.author, 'post', urip.rkey), urip.rkey]
}, [post.uri, post.author]) }, [post.uri, post.author])
const {sendInteraction, feedSourceInfo} = useFeedFeedbackContext() const {sendInteraction, feedSourceInfo, feedDescriptor} =
useFeedFeedbackContext()
const onPressReply = () => { const onPressReply = () => {
sendInteraction({ sendInteraction({
@@ -209,6 +211,12 @@ let FeedItemInner = ({
feedContext, feedContext,
reqId, reqId,
}) })
logger.metric('post:clickthroughAuthor', {
uri: post.uri,
authorDid: post.author.did,
logContext: 'FeedItem',
feedDescriptor,
})
} }
const onOpenReposter = () => { const onOpenReposter = () => {
@@ -227,6 +235,12 @@ let FeedItemInner = ({
feedContext, feedContext,
reqId, reqId,
}) })
logger.metric('post:clickthroughEmbed', {
uri: post.uri,
authorDid: post.author.did,
logContext: 'FeedItem',
feedDescriptor,
})
} }
const onBeforePress = () => { const onBeforePress = () => {
@@ -236,6 +250,12 @@ let FeedItemInner = ({
feedContext, feedContext,
reqId, reqId,
}) })
logger.metric('post:clickthroughItem', {
uri: post.uri,
authorDid: post.author.did,
logContext: 'FeedItem',
feedDescriptor,
})
unstableCacheProfileView(queryClient, post.author) unstableCacheProfileView(queryClient, post.author)
setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), { setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), {
feedSourceInfo, feedSourceInfo,
+148 -36
View File
@@ -1,4 +1,4 @@
import {View} from 'react-native' import {Pressable, View} from 'react-native'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation, useNavigationState} from '@react-navigation/native' import {useNavigation, useNavigationState} from '@react-navigation/native'
@@ -7,10 +7,18 @@ import {getCurrentRoute} from '#/lib/routes/helpers'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {logger} from '#/logger' import {logger} from '#/logger'
import {emitSoftReset} from '#/state/events' import {emitSoftReset} from '#/state/events'
import {usePinnedFeedsInfos} from '#/state/queries/feed' import {
type SavedFeedSourceInfo,
usePinnedFeedsInfos,
} from '#/state/queries/feed'
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf' import {atoms as a, useTheme, web} from '#/alf'
import {createStaticClick, InlineLinkText} from '#/components/Link' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
export function DesktopFeeds() { export function DesktopFeeds() {
const t = useTheme() const t = useTheme()
@@ -57,13 +65,12 @@ export function DesktopFeeds() {
style={[ style={[
a.flex_1, a.flex_1,
web({ web({
gap: 10, gap: 2,
/* /*
* Small padding prevents overflow prior to actually overflowing the * Small padding prevents overflow prior to actually overflowing the
* height of the screen with lots of feeds. * height of the screen with lots of feeds.
*/ */
paddingVertical: 2, paddingTop: 2,
marginHorizontal: -2,
overflowY: 'auto', overflowY: 'auto',
}), }),
]}> ]}>
@@ -72,10 +79,11 @@ export function DesktopFeeds() {
const current = route.name === 'Home' && feed === selectedFeed const current = route.name === 'Home' && feed === selectedFeed
return ( return (
<InlineLinkText <FeedItem
key={feedInfo.uri} key={feedInfo.uri}
label={feedInfo.displayName} feedInfo={feedInfo}
{...createStaticClick(() => { current={current}
onPress={() => {
logger.metric( logger.metric(
'desktopFeeds:feed:click', 'desktopFeeds:feed:click',
{ {
@@ -89,39 +97,143 @@ export function DesktopFeeds() {
if (route.name === 'Home' && feed === selectedFeed) { if (route.name === 'Home' && feed === selectedFeed) {
emitSoftReset() emitSoftReset()
} }
})} }}
style={[ />
a.text_md,
a.leading_snug,
a.flex_shrink_0,
current
? [a.font_semi_bold, t.atoms.text]
: [t.atoms.text_contrast_medium],
web({
marginHorizontal: 2,
width: 'calc(100% - 4px)',
}),
]}
numberOfLines={1}>
{feedInfo.displayName}
</InlineLinkText>
) )
})} })}
<InlineLinkText <Link
to="/feeds" to="/feeds"
label={_(msg`More feeds`)} label={_(msg`More feeds`)}
style={[ style={[
a.text_md, a.flex_row,
a.leading_snug, a.align_center,
web({ a.gap_sm,
marginHorizontal: 2, a.self_start,
width: 'calc(100% - 4px)', a.rounded_sm,
}), {paddingVertical: 6, paddingHorizontal: 8},
]} route.name === 'Feeds' && {backgroundColor: t.palette.primary_50},
numberOfLines={1}> ]}>
{_(msg`More feeds`)} {({hovered}) => {
</InlineLinkText> const isActive = route.name === 'Feeds'
return (
<>
<View
style={[
a.align_center,
a.justify_center,
a.rounded_xs,
isActive
? {backgroundColor: t.palette.primary_100}
: t.atoms.bg_contrast_50,
{
width: 20,
height: 20,
},
]}>
<Plus
style={{width: 16, height: 16}}
fill={
isActive || hovered
? t.atoms.text.color
: t.atoms.text_contrast_medium.color
}
/>
</View>
<Text
style={[
a.text_md,
a.leading_snug,
isActive
? [t.atoms.text, a.font_semi_bold]
: hovered
? t.atoms.text
: t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{_(msg`More feeds`)}
</Text>
</>
)
}}
</Link>
</View> </View>
) )
} }
function FeedItem({
feedInfo,
current,
onPress,
}: {
feedInfo: SavedFeedSourceInfo
current: boolean
onPress: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const isFollowing = feedInfo.feedDescriptor === 'following'
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={feedInfo.displayName}
accessibilityHint={_(msg`Opens ${feedInfo.displayName} feed`)}
onPress={onPress}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
style={[
a.flex_row,
a.align_center,
a.gap_sm,
a.self_start,
a.rounded_sm,
{paddingVertical: 6, paddingHorizontal: 8},
current && {backgroundColor: t.palette.primary_50},
]}>
{isFollowing ? (
<View
style={[
a.align_center,
a.justify_center,
a.rounded_xs,
{
width: 20,
height: 20,
backgroundColor: t.palette.primary_500,
},
]}>
<FilterTimeline
style={{width: 14, height: 14}}
fill={t.palette.white}
/>
</View>
) : (
<UserAvatar
type={feedInfo.type === 'list' ? 'list' : 'algo'}
size={20}
avatar={feedInfo.avatar}
noBorder
/>
)}
<Text
style={[
a.text_md,
a.leading_snug,
current
? [t.atoms.text, a.font_semi_bold]
: hovered
? t.atoms.text
: t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{feedInfo.displayName}
</Text>
</Pressable>
)
}
+16 -9
View File
@@ -18,11 +18,11 @@ import {
web, web,
} from '#/alf' } from '#/alf'
import {AppLanguageDropdown} from '#/components/AppLanguageDropdown' import {AppLanguageDropdown} from '#/components/AppLanguageDropdown'
import {Divider} from '#/components/Divider'
import {CENTER_COLUMN_OFFSET} from '#/components/Layout' import {CENTER_COLUMN_OFFSET} from '#/components/Layout'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {ProgressGuideList} from '#/components/ProgressGuide/List' import {ProgressGuideList} from '#/components/ProgressGuide/List'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {SidebarLiveEventFeedsBanner} from '#/features/liveEvents/components/SidebarLiveEventFeedsBanner'
function useWebQueryParams() { function useWebQueryParams() {
const navigation = useNavigation() const navigation = useNavigation()
@@ -50,7 +50,8 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
const isSearchScreen = routeName === 'Search' const isSearchScreen = routeName === 'Search'
const webqueryParams = useWebQueryParams() const webqueryParams = useWebQueryParams()
const searchQuery = webqueryParams?.q const searchQuery = webqueryParams?.q
const showTrending = !isSearchScreen || (isSearchScreen && !!searchQuery) const showExploreScreenDuplicatedContent =
!isSearchScreen || (isSearchScreen && !!searchQuery)
const {rightNavVisible, centerColumnOffset, leftNavMinimal} = const {rightNavVisible, centerColumnOffset, leftNavMinimal} =
useLayoutBreakpoints() useLayoutBreakpoints()
@@ -86,13 +87,13 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
{hasSession && ( {hasSession && (
<> <>
<ProgressGuideList />
<DesktopFeeds /> <DesktopFeeds />
<Divider /> <ProgressGuideList />
</> </>
)} )}
{showTrending && <SidebarTrendingTopics />} {showExploreScreenDuplicatedContent && <SidebarLiveEventFeedsBanner />}
{showExploreScreenDuplicatedContent && <SidebarTrendingTopics />}
<Text style={[a.leading_snug, t.atoms.text_contrast_low]}> <Text style={[a.leading_snug, t.atoms.text_contrast_low]}>
{hasSession && ( {hasSession && (
@@ -102,25 +103,31 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
email: currentAccount?.email, email: currentAccount?.email,
handle: currentAccount?.handle, handle: currentAccount?.handle,
})} })}
style={[t.atoms.text_contrast_medium]}
label={_(msg`Feedback`)}> label={_(msg`Feedback`)}>
{_(msg`Feedback`)} {_(msg`Feedback`)}
</InlineLinkText> </InlineLinkText>
{' '} <Text style={[t.atoms.text_contrast_low]}>{' '}</Text>
</> </>
)} )}
<InlineLinkText <InlineLinkText
to="https://bsky.social/about/support/privacy-policy" to="https://bsky.social/about/support/privacy-policy"
style={[t.atoms.text_contrast_medium]}
label={_(msg`Privacy`)}> label={_(msg`Privacy`)}>
{_(msg`Privacy`)} {_(msg`Privacy`)}
</InlineLinkText> </InlineLinkText>
{' '} <Text style={[t.atoms.text_contrast_low]}>{' '}</Text>
<InlineLinkText <InlineLinkText
to="https://bsky.social/about/support/tos" to="https://bsky.social/about/support/tos"
style={[t.atoms.text_contrast_medium]}
label={_(msg`Terms`)}> label={_(msg`Terms`)}>
{_(msg`Terms`)} {_(msg`Terms`)}
</InlineLinkText> </InlineLinkText>
{' '} <Text style={[t.atoms.text_contrast_low]}>{' '}</Text>
<InlineLinkText label={_(msg`Help`)} to={HELP_DESK_URL}> <InlineLinkText
label={_(msg`Help`)}
to={HELP_DESK_URL}
style={[t.atoms.text_contrast_medium]}>
{_(msg`Help`)} {_(msg`Help`)}
</InlineLinkText> </InlineLinkText>
</Text> </Text>
@@ -1,9 +1,8 @@
import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger'
import { import {
useTrendingSettings, useTrendingSettings,
useTrendingSettingsApi, useTrendingSettingsApi,
@@ -12,18 +11,13 @@ import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics'
import {useTrendingConfig} from '#/state/service-config' import {useTrendingConfig} from '#/state/service-config'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import {Divider} from '#/components/Divider' import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import { import {TrendingTopicLink} from '#/components/TrendingTopics'
TrendingTopic,
TrendingTopicLink,
TrendingTopicSkeleton,
} from '#/components/TrendingTopics'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
const TRENDING_LIMIT = 6 const TRENDING_LIMIT = 5
export function SidebarTrendingTopics() { export function SidebarTrendingTopics() {
const {enabled} = useTrendingConfig() const {enabled} = useTrendingConfig()
@@ -39,64 +33,88 @@ function Inner() {
const {data: trending, error, isLoading} = useTrendingTopics() const {data: trending, error, isLoading} = useTrendingTopics()
const noTopics = !isLoading && !error && !trending?.topics?.length const noTopics = !isLoading && !error && !trending?.topics?.length
const onConfirmHide = React.useCallback(() => { const onConfirmHide = () => {
logEvent('trendingTopics:hide', {context: 'sidebar'}) logger.metric('trendingTopics:hide', {context: 'sidebar'})
setTrendingDisabled(true) setTrendingDisabled(true)
}, [setTrendingDisabled]) }
return error || noTopics ? null : ( return error || noTopics ? null : (
<> <>
<View style={[a.gap_sm, {paddingBottom: 2}]}> <View
<View style={[a.flex_row, a.align_center, a.gap_xs]}> style={[a.p_lg, a.rounded_md, a.border, t.atoms.border_contrast_low]}>
<Graph size="sm" /> <View style={[a.flex_row, a.align_center, a.gap_xs, a.pb_md]}>
<Text <TrendingIcon width={16} height={16} fill={t.atoms.text.color} />
style={[ <Text style={[a.flex_1, a.text_md, a.font_semi_bold, t.atoms.text]}>
a.flex_1,
a.text_sm,
a.font_semi_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Trending</Trans> <Trans>Trending</Trans>
</Text> </Text>
<Button <Button
label={_(msg`Hide trending topics`)}
size="tiny"
variant="ghost" variant="ghost"
size="tiny"
color="secondary" color="secondary"
shape="round" shape="round"
onPress={() => trendingPrompt.open()}> label={_(msg`Trending options`)}
<ButtonIcon icon={X} /> onPress={() => trendingPrompt.open()}
style={[a.bg_transparent, {marginTop: -6, marginRight: -6}]}>
<ButtonIcon icon={Ellipsis} size="xs" />
</Button> </Button>
</View> </View>
<View style={[a.flex_row, a.flex_wrap, {gap: '6px 4px'}]}> <View style={[a.gap_xs]}>
{isLoading ? ( {isLoading ? (
Array(TRENDING_LIMIT) Array(TRENDING_LIMIT)
.fill(0) .fill(0)
.map((_n, i) => ( .map((_n, i) => (
<TrendingTopicSkeleton key={i} size="small" index={i} /> <View key={i} style={[a.flex_row, a.align_center, a.gap_sm]}>
<Text
style={[
a.text_sm,
t.atoms.text_contrast_low,
{minWidth: 16},
]}>
{i + 1}.
</Text>
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_50,
{height: 14, width: i % 2 === 0 ? 80 : 100},
]}
/>
</View>
)) ))
) : !trending?.topics ? null : ( ) : !trending?.topics ? null : (
<> <>
{trending.topics.slice(0, TRENDING_LIMIT).map(topic => ( {trending.topics.slice(0, TRENDING_LIMIT).map((topic, i) => (
<TrendingTopicLink <TrendingTopicLink
key={topic.link} key={topic.link}
topic={topic} topic={topic}
style={a.rounded_full} style={[a.self_start]}
onPress={() => { onPress={() => {
logEvent('trendingTopic:click', {context: 'sidebar'}) logger.metric('trendingTopic:click', {context: 'sidebar'})
}}> }}>
{({hovered}) => ( {({hovered}) => (
<TrendingTopic <View style={[a.flex_row, a.align_center, a.gap_xs]}>
size="small" <Text
topic={topic} style={[
style={[ a.text_sm,
hovered && [ a.leading_snug,
t.atoms.border_contrast_high, t.atoms.text_contrast_low,
t.atoms.bg_contrast_25, {minWidth: 16},
], ]}>
]} {i + 1}.
/> </Text>
<Text
style={[
a.text_sm,
a.leading_snug,
hovered
? [t.atoms.text, a.underline]
: t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{topic.displayName ?? topic.topic}
</Text>
</View>
)} )}
</TrendingTopicLink> </TrendingTopicLink>
))} ))}
@@ -111,7 +129,6 @@ function Inner() {
confirmButtonCta={_(msg`Hide`)} confirmButtonCta={_(msg`Hide`)}
onConfirm={onConfirmHide} onConfirm={onConfirmHide}
/> />
<Divider />
</> </>
) )
} }
+21 -28
View File
@@ -82,12 +82,12 @@
"@atproto/xrpc" "^0.7.6" "@atproto/xrpc" "^0.7.6"
"@atproto/xrpc-server" "^0.10.0" "@atproto/xrpc-server" "^0.10.0"
"@atproto/api@^0.18.13": "@atproto/api@^0.18.15":
version "0.18.13" version "0.18.15"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.13.tgz#63eee310e6715752eb87748323cf9ab57dd91e4b" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.15.tgz#25ce82081216bdefbf5397220de76ac3ba9e3f5d"
integrity sha512-CULZ01pSJDltLS/Gc9MMrhFzB6OM3ezyZw7KoeLT/sBfwgA1ddA4mWdTh7DIRosPRigXtA05bnoiCutZbQDo+Q== integrity sha512-GeaTP7HMRZa8jD6trMuTACa8t2jkFtRmcwWgrB0FT7l9jVCXrKpYupWeIeauEgWHNwWUUiaq3LmCox+HBy8ZMQ==
dependencies: dependencies:
"@atproto/common-web" "^0.4.11" "@atproto/common-web" "^0.4.12"
"@atproto/lexicon" "^0.6.0" "@atproto/lexicon" "^0.6.0"
"@atproto/syntax" "^0.4.2" "@atproto/syntax" "^0.4.2"
"@atproto/xrpc" "^0.7.7" "@atproto/xrpc" "^0.7.7"
@@ -208,13 +208,13 @@
pino-http "^8.2.1" pino-http "^8.2.1"
typed-emitter "^2.1.0" typed-emitter "^2.1.0"
"@atproto/common-web@^0.4.11": "@atproto/common-web@^0.4.12":
version "0.4.11" version "0.4.12"
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.11.tgz#eb41dc02c1ea4221388630e193d181fb098186e0" resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.12.tgz#04135bef480d9e12cfef124ee45d8236764e7509"
integrity sha512-VHejNmSABU8/03VrQ3e36AmT5U3UIeio+qSUqCrO1oNgrJcWfGy1rpj0FVtUugWF8Un29+yzkukzWGZfXL70rQ== integrity sha512-3aCJemqM/fkHQrVPbTCHCdiVstKFI+2LkFLvUhO6XZP0EqUZa/rg/CIZBKTFUWu9I5iYiaEiXL9VwcDRpEevSw==
dependencies: dependencies:
"@atproto/lex-data" "0.0.7" "@atproto/lex-data" "0.0.8"
"@atproto/lex-json" "0.0.7" "@atproto/lex-json" "0.0.8"
zod "^3.23.8" zod "^3.23.8"
"@atproto/common-web@^0.4.4", "@atproto/common-web@^0.4.6": "@atproto/common-web@^0.4.4", "@atproto/common-web@^0.4.6":
@@ -415,10 +415,10 @@
uint8arrays "3.0.0" uint8arrays "3.0.0"
unicode-segmenter "^0.14.0" unicode-segmenter "^0.14.0"
"@atproto/lex-data@0.0.7": "@atproto/lex-data@0.0.8":
version "0.0.7" version "0.0.8"
resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.7.tgz#6aa87423f6d47849bec8ff3ca0b00ce93964adc8" resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.8.tgz#46cc261efbfa6cc05bf04439d2d73cd8386b467d"
integrity sha512-W/Q5o9o7n2Sv3UywckChu01X5lwQUtaiiOkGJLnRsdkQTyC6813nPgY+p2sG7NwwM+82lu+FUV9fE/Ul3VqaJw== integrity sha512-1Y5tz7BkS7380QuLNXaE8GW8Xba+mRWugt8BKM4BUFYjjUZdmirU8lr72iM4XlEBrzRu8Cfvj+MbsbYaZv+IgA==
dependencies: dependencies:
"@atproto/syntax" "0.4.2" "@atproto/syntax" "0.4.2"
multiformats "^9.9.0" multiformats "^9.9.0"
@@ -451,12 +451,12 @@
"@atproto/lex-data" "0.0.3" "@atproto/lex-data" "0.0.3"
tslib "^2.8.1" tslib "^2.8.1"
"@atproto/lex-json@0.0.7": "@atproto/lex-json@0.0.8":
version "0.0.7" version "0.0.8"
resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.7.tgz#c06e1fc3e06d739bbb74694f5d846055bed37866" resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.8.tgz#03290762d9368b029488ee0a0766d1a34063255c"
integrity sha512-bjNPD5M/MhLfjNM7tcxuls80UgXpHqxdOxDXEUouAtZQV/nIDhGjmNUvKxOmOgnDsiZRnT2g5y3onrnjH3a44g== integrity sha512-w1Qmkae1QhmNz+i1Zm3xr3jp0UPPRENmdlpU0qIrdxWDo9W4Mzkeyc3eSoa+Zs+zN8xkRSQw7RLZte/B7Ipdwg==
dependencies: dependencies:
"@atproto/lex-data" "0.0.7" "@atproto/lex-data" "0.0.8"
tslib "^2.8.1" tslib "^2.8.1"
"@atproto/lex-resolver@0.0.5": "@atproto/lex-resolver@0.0.5":
@@ -6144,20 +6144,13 @@
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb" resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb"
integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw== 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" version "2.2.0"
resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz#a3aa565253e46286655560172f4e366e8969f5ad" resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz#a3aa565253e46286655560172f4e366e8969f5ad"
integrity sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw== integrity sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==
dependencies: dependencies:
merge-options "^3.0.4" 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": "@react-native/assets-registry@0.81.5":
version "0.81.5" version "0.81.5"
resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.81.5.tgz#d22c924fa6f6d4a463c5af34ce91f38756c0fa7d" resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.81.5.tgz#d22c924fa6f6d4a463c5af34ce91f38756c0fa7d"