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:
+5
-2
@@ -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=
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ module.exports = {
|
|||||||
'Admonition',
|
'Admonition',
|
||||||
'Admonition.Admonition',
|
'Admonition.Admonition',
|
||||||
'Toast.Action',
|
'Toast.Action',
|
||||||
|
'toast.Action',
|
||||||
'AgeAssuranceAdmonition',
|
'AgeAssuranceAdmonition',
|
||||||
'Span',
|
'Span',
|
||||||
'StackedButton',
|
'StackedButton',
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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` |
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"setup": "yarn install",
|
||||||
|
"run": "yarn web --port $CONDUCTOR_PORT"
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,6 +146,7 @@ function InnerApp() {
|
|||||||
<QueryProvider currentDid={currentAccount?.did}>
|
<QueryProvider currentDid={currentAccount?.did}>
|
||||||
<PolicyUpdateOverlayProvider>
|
<PolicyUpdateOverlayProvider>
|
||||||
<StatsigProvider>
|
<StatsigProvider>
|
||||||
|
<LiveEventsProvider>
|
||||||
<AgeAssuranceV2Provider>
|
<AgeAssuranceV2Provider>
|
||||||
<ComposerProvider>
|
<ComposerProvider>
|
||||||
<MessagesProvider>
|
<MessagesProvider>
|
||||||
@@ -184,6 +190,7 @@ function InnerApp() {
|
|||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
</ComposerProvider>
|
</ComposerProvider>
|
||||||
</AgeAssuranceV2Provider>
|
</AgeAssuranceV2Provider>
|
||||||
|
</LiveEventsProvider>
|
||||||
</StatsigProvider>
|
</StatsigProvider>
|
||||||
</PolicyUpdateOverlayProvider>
|
</PolicyUpdateOverlayProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
|
|||||||
@@ -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,6 +122,7 @@ function InnerApp() {
|
|||||||
<QueryProvider currentDid={currentAccount?.did}>
|
<QueryProvider currentDid={currentAccount?.did}>
|
||||||
<PolicyUpdateOverlayProvider>
|
<PolicyUpdateOverlayProvider>
|
||||||
<StatsigProvider>
|
<StatsigProvider>
|
||||||
|
<LiveEventsProvider>
|
||||||
<AgeAssuranceV2Provider>
|
<AgeAssuranceV2Provider>
|
||||||
<ComposerProvider>
|
<ComposerProvider>
|
||||||
<MessagesProvider>
|
<MessagesProvider>
|
||||||
@@ -156,6 +162,7 @@ function InnerApp() {
|
|||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
</ComposerProvider>
|
</ComposerProvider>
|
||||||
</AgeAssuranceV2Provider>
|
</AgeAssuranceV2Provider>
|
||||||
|
</LiveEventsProvider>
|
||||||
</StatsigProvider>
|
</StatsigProvider>
|
||||||
</PolicyUpdateOverlayProvider>
|
</PolicyUpdateOverlayProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
|
|||||||
+6
-1
@@ -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'
|
||||||
|
|||||||
@@ -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]}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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
|
||||||
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
|
||||||
<Text
|
|
||||||
style={[
|
style={[
|
||||||
t.atoms.text_contrast_medium,
|
a.flex_col,
|
||||||
a.font_semi_bold,
|
a.gap_md,
|
||||||
a.text_sm,
|
a.rounded_md,
|
||||||
{textTransform: 'uppercase'},
|
t.atoms.bg_contrast_25,
|
||||||
|
a.p_lg,
|
||||||
|
style,
|
||||||
]}>
|
]}>
|
||||||
<Trans>Getting started</Trans>
|
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
||||||
|
<Text style={[t.atoms.text, a.font_semi_bold, a.text_md]}>
|
||||||
|
<Trans>Follow 10 people to get 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
Vendored
+15
-5
@@ -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"
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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!
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
+410
-342
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,4 +45,8 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
|
|||||||
verificationPrefs: {
|
verificationPrefs: {
|
||||||
hideBadges: false,
|
hideBadges: false,
|
||||||
},
|
},
|
||||||
|
liveEventPreferences: {
|
||||||
|
hideAllFeeds: false,
|
||||||
|
hiddenFeedIds: [],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: 'interstitialTrending',
|
type: 'liveEventFeedsAndTrendingBanner',
|
||||||
key:
|
key: 'liveEventFeedsAndTrendingBanner-' + sliceIndex,
|
||||||
'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,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,
|
||||||
|
|||||||
@@ -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={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
a.self_start,
|
||||||
|
a.rounded_sm,
|
||||||
|
{paddingVertical: 6, paddingHorizontal: 8},
|
||||||
|
route.name === 'Feeds' && {backgroundColor: t.palette.primary_50},
|
||||||
|
]}>
|
||||||
|
{({hovered}) => {
|
||||||
|
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={[
|
style={[
|
||||||
a.text_md,
|
a.text_md,
|
||||||
a.leading_snug,
|
a.leading_snug,
|
||||||
web({
|
isActive
|
||||||
marginHorizontal: 2,
|
? [t.atoms.text, a.font_semi_bold]
|
||||||
width: 'calc(100% - 4px)',
|
: hovered
|
||||||
}),
|
? t.atoms.text
|
||||||
|
: t.atoms.text_contrast_medium,
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}>
|
numberOfLines={1}>
|
||||||
{_(msg`More feeds`)}
|
{_(msg`More feeds`)}
|
||||||
</InlineLinkText>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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={[
|
||||||
hovered && [
|
a.text_sm,
|
||||||
t.atoms.border_contrast_high,
|
a.leading_snug,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.text_contrast_low,
|
||||||
],
|
{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 />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user