Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a50a68e960 | |||
| 1d4263078d | |||
| 367eebe600 | |||
| 06a8a7efc2 | |||
| 29f8bc998d | |||
| 4760e59ea4 | |||
| d9e37a222a | |||
| 3b98f2f8ab | |||
| ab85b509c0 | |||
| 65faee4f08 | |||
| 94be661794 | |||
| 10aa46aa9e | |||
| 8f82ad66df | |||
| 1cb362b596 | |||
| f960d2fbc6 | |||
| 58ac9a3e7b | |||
| bbab7695f9 | |||
| 26aa7c0d9a | |||
| 498a09e797 | |||
| 2223babc2b | |||
| 5922c6622f | |||
| e684c21572 | |||
| 226b19ae43 | |||
| cb6e65e313 | |||
| af92610864 | |||
| ca905eb3e1 | |||
| 61c919110e | |||
| 9aa360d328 | |||
| 2d8de1ddc3 | |||
| c540dae4e7 | |||
| 2818c7f508 | |||
| c4fd9980cc | |||
| 966ae68dd2 | |||
| d2aea9161b | |||
| edb19dd6cf | |||
| 85a616eee0 | |||
| 7285157ab5 | |||
| b3f775d1d8 | |||
| a1857d62bd | |||
| d82592f072 | |||
| dae03312c9 | |||
| c2e5a18e42 | |||
| 2efc047f64 | |||
| 80049bbe82 | |||
| e627d37f42 | |||
| 24fe45aaa1 | |||
| 0deb9511af | |||
| 43ebb80a5b | |||
| d91e206e21 | |||
| 493bd6dcf6 | |||
| 4f6ff6fc73 | |||
| 7ba2997af5 | |||
| 4b6fbcc050 | |||
| 55806f1087 | |||
| 0aaf11aa7b | |||
| 9cfa7d0ba8 | |||
| 9743149c26 |
+5
-2
@@ -34,5 +34,8 @@ EXPO_PUBLIC_SENTRY_DSN=
|
||||
# Bitdrift API key. If undefined, Bitdrift will be disabled.
|
||||
EXPO_PUBLIC_BITDRIFT_API_KEY=
|
||||
|
||||
# bapp-config web worker URL
|
||||
BAPP_CONFIG_DEV_URL=
|
||||
# geolocation web worker URL
|
||||
GEOLOCATION_DEV_URL=
|
||||
|
||||
# live-events web worker URL
|
||||
LIVE_EVENTS_DEV_URL=
|
||||
|
||||
@@ -35,6 +35,7 @@ module.exports = {
|
||||
'Admonition',
|
||||
'Admonition.Admonition',
|
||||
'Toast.Action',
|
||||
'toast.Action',
|
||||
'AgeAssuranceAdmonition',
|
||||
'Span',
|
||||
'StackedButton',
|
||||
|
||||
@@ -16,6 +16,9 @@ jobs:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
name: Build and Submit Android
|
||||
runs-on: Linux-x64-32core
|
||||
concurrency:
|
||||
group: android-build
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
|
||||
@@ -16,6 +16,9 @@ jobs:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
name: Build and Submit iOS
|
||||
runs-on: macos-26-xlarge
|
||||
concurrency:
|
||||
group: ios-build
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
|
||||
@@ -157,8 +157,8 @@ jobs:
|
||||
name: Build and Submit iOS
|
||||
runs-on: macos-26
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios
|
||||
cancel-in-progress: true
|
||||
group: ios-build
|
||||
cancel-in-progress: false
|
||||
needs: [bundleDeploy]
|
||||
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
|
||||
# available here
|
||||
@@ -262,7 +262,7 @@ jobs:
|
||||
name: Build and Submit Android
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-android
|
||||
group: android-build
|
||||
cancel-in-progress: false
|
||||
needs: [bundleDeploy]
|
||||
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
|
||||
|
||||
@@ -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
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
with:
|
||||
base_path: "stats-base.json"
|
||||
pr_path: "../stats-new.json"
|
||||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png"
|
||||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
# CLAUDE.md - Bluesky Social App Development Guide
|
||||
|
||||
This document provides guidance for working effectively in the Bluesky Social app codebase.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Bluesky Social is a cross-platform social media application built with React Native and Expo. It runs on iOS, Android, and Web, connecting to the AT Protocol (atproto) decentralized social network.
|
||||
|
||||
**Tech Stack:**
|
||||
- React Native 0.81 with Expo 54
|
||||
- TypeScript
|
||||
- React Navigation for routing
|
||||
- TanStack Query (React Query) for data fetching
|
||||
- Lingui for internationalization
|
||||
- Custom design system called ALF (Application Layout Framework)
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
yarn start # Start Expo dev server
|
||||
yarn web # Start web version
|
||||
yarn android # Run on Android
|
||||
yarn ios # Run on iOS
|
||||
|
||||
# Testing & Quality
|
||||
yarn test # Run Jest tests
|
||||
yarn lint # Run ESLint
|
||||
yarn typecheck # Run TypeScript type checking
|
||||
|
||||
# Internationalization
|
||||
yarn intl:extract # Extract translation strings
|
||||
yarn intl:compile # Compile translations for runtime
|
||||
|
||||
# Build
|
||||
yarn build-web # Build web version
|
||||
yarn prebuild # Generate native projects
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── alf/ # Design system (ALF) - themes, atoms, tokens
|
||||
├── components/ # Shared UI components (Button, Dialog, Menu, etc.)
|
||||
├── screens/ # Full-page screen components (newer pattern)
|
||||
├── view/
|
||||
│ ├── screens/ # Full-page screens (legacy location)
|
||||
│ ├── com/ # Reusable view components
|
||||
│ └── shell/ # App shell (navigation bars, tabs)
|
||||
├── state/
|
||||
│ ├── queries/ # TanStack Query hooks
|
||||
│ ├── preferences/ # User preferences (React Context)
|
||||
│ ├── session/ # Authentication state
|
||||
│ └── persisted/ # Persistent storage layer
|
||||
├── lib/ # Utilities, constants, helpers
|
||||
├── locale/ # i18n configuration and language files
|
||||
└── Navigation.tsx # Main navigation configuration
|
||||
```
|
||||
|
||||
## Styling System (ALF)
|
||||
|
||||
ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
function MyComponent() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
Hello
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
**Static Atoms** - Theme-independent styles imported from `atoms`:
|
||||
```tsx
|
||||
import {atoms as a} from '#/alf'
|
||||
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
|
||||
```
|
||||
|
||||
**Theme Atoms** - Theme-dependent colors from `useTheme()`:
|
||||
```tsx
|
||||
const t = useTheme()
|
||||
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
|
||||
// t.palette.primary_500, t.palette.negative_400, etc.
|
||||
```
|
||||
|
||||
**Platform Utilities** - For platform-specific styles:
|
||||
```tsx
|
||||
import {web, native, ios, android, platform} from '#/alf'
|
||||
|
||||
const styles = [
|
||||
a.p_md,
|
||||
web({cursor: 'pointer'}),
|
||||
native({paddingBottom: 20}),
|
||||
platform({ios: {...}, android: {...}, web: {...}}),
|
||||
]
|
||||
```
|
||||
|
||||
**Breakpoints** - Responsive design:
|
||||
```tsx
|
||||
import {useBreakpoints} from '#/alf'
|
||||
|
||||
const {gtPhone, gtMobile, gtTablet} = useBreakpoints()
|
||||
if (gtMobile) {
|
||||
// Tablet or desktop layout
|
||||
}
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- Spacing: `xxs`, `xs`, `sm`, `md`, `lg`, `xl`, `xxl` (t-shirt sizes)
|
||||
- Text: `text_xs`, `text_sm`, `text_md`, `text_lg`, `text_xl`
|
||||
- Gaps/Padding: `gap_sm`, `p_md`, `px_lg`, `py_xl`
|
||||
- Flex: `flex_row`, `flex_1`, `align_center`, `justify_between`
|
||||
- Borders: `border`, `border_t`, `rounded_md`, `rounded_full`
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Dialog Component
|
||||
|
||||
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
|
||||
|
||||
```tsx
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
|
||||
function MyFeature() {
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button label="Open" onPress={control.open}>
|
||||
<ButtonText>Open Dialog</ButtonText>
|
||||
</Button>
|
||||
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle /> {/* Native drag handle */}
|
||||
<Dialog.ScrollableInner label={_(msg`My Dialog`)}>
|
||||
<Dialog.Header>
|
||||
<Dialog.HeaderText>Title</Dialog.HeaderText>
|
||||
</Dialog.Header>
|
||||
|
||||
<Text>Dialog content here</Text>
|
||||
|
||||
<Button label="Close" onPress={() => control.close()}>
|
||||
<ButtonText>Close</ButtonText>
|
||||
</Button>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Menu Component
|
||||
|
||||
Menus render as a dropdown on web and a bottom sheet dialog on native.
|
||||
|
||||
```tsx
|
||||
import * as Menu from '#/components/Menu'
|
||||
|
||||
function MyMenu() {
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label="Open menu">
|
||||
{({props}) => (
|
||||
<Button {...props} label="Menu">
|
||||
<ButtonIcon icon={DotsHorizontal} />
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
<Menu.Item label="Edit" onPress={handleEdit}>
|
||||
<Menu.ItemIcon icon={Pencil} />
|
||||
<Menu.ItemText>Edit</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
<Menu.Item label="Delete" onPress={handleDelete}>
|
||||
<Menu.ItemIcon icon={Trash} />
|
||||
<Menu.ItemText>Delete</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Button Component
|
||||
|
||||
```tsx
|
||||
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||
|
||||
// Solid primary button (most common)
|
||||
<Button label="Save" onPress={handleSave} color="primary" size="large">
|
||||
<ButtonText>Save</ButtonText>
|
||||
</Button>
|
||||
|
||||
// With icon
|
||||
<Button label="Share" onPress={handleShare} color="secondary" size="small">
|
||||
<ButtonIcon icon={Share} />
|
||||
<ButtonText>Share</ButtonText>
|
||||
</Button>
|
||||
|
||||
// Icon-only button
|
||||
<Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round">
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
|
||||
// Ghost variant (deprecated - use color prop)
|
||||
<Button label="Cancel" variant="ghost" color="secondary" size="small">
|
||||
<ButtonText>Cancel</ButtonText>
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Button Props:**
|
||||
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'`
|
||||
- `size`: `'tiny'` | `'small'` | `'large'`
|
||||
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
|
||||
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`)
|
||||
|
||||
### Typography
|
||||
|
||||
```tsx
|
||||
import {Text, H1, H2, P} from '#/components/Typography'
|
||||
|
||||
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
|
||||
<P>Paragraph text with default styling.</P>
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text>
|
||||
|
||||
// For text with emoji, add the emoji prop
|
||||
<Text emoji>Hello! 👋</Text>
|
||||
```
|
||||
|
||||
### TextField
|
||||
|
||||
```tsx
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
|
||||
<TextField.LabelText>Email</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={AtSign} />
|
||||
<TextField.Input
|
||||
label="Email address"
|
||||
placeholder="you@example.com"
|
||||
defaultValue={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</TextField.Root>
|
||||
```
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
All user-facing strings must be wrapped for translation using Lingui.
|
||||
|
||||
```tsx
|
||||
import {msg, Trans, plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
function MyComponent() {
|
||||
const {_} = useLingui()
|
||||
|
||||
// Simple strings - use msg() with _() function
|
||||
const title = _(msg`Settings`)
|
||||
const errorMessage = _(msg`Something went wrong`)
|
||||
|
||||
// Strings with variables
|
||||
const greeting = _(msg`Hello, ${name}!`)
|
||||
|
||||
// Pluralization
|
||||
const countLabel = _(plural(count, {
|
||||
one: '# item',
|
||||
other: '# items',
|
||||
}))
|
||||
|
||||
// JSX content - use Trans component
|
||||
return (
|
||||
<Text>
|
||||
<Trans>Welcome to <Text style={a.font_bold}>Bluesky</Text></Trans>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
yarn intl:extract # Extract new strings to locale files
|
||||
yarn intl:compile # Compile for runtime (required after changes)
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### TanStack Query (Data Fetching)
|
||||
|
||||
```tsx
|
||||
// src/state/queries/profile.ts
|
||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
// Query key pattern
|
||||
const RQKEY_ROOT = 'profile'
|
||||
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
|
||||
|
||||
// Query hook
|
||||
export function useProfileQuery({did}: {did: string}) {
|
||||
const agent = useAgent()
|
||||
|
||||
return useQuery({
|
||||
queryKey: RQKEY(did),
|
||||
queryFn: async () => {
|
||||
const res = await agent.getProfile({actor: did})
|
||||
return res.data
|
||||
},
|
||||
staleTime: STALE.MINUTES.FIVE,
|
||||
enabled: !!did,
|
||||
})
|
||||
}
|
||||
|
||||
// Mutation hook
|
||||
export function useUpdateProfile() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data) => {
|
||||
// Update logic
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({queryKey: RQKEY(variables.did)})
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Stale Time Constants** (from `src/state/queries/index.ts`):
|
||||
```tsx
|
||||
STALE.SECONDS.FIFTEEN // 15 seconds
|
||||
STALE.MINUTES.ONE // 1 minute
|
||||
STALE.MINUTES.FIVE // 5 minutes
|
||||
STALE.HOURS.ONE // 1 hour
|
||||
STALE.INFINITY // Never stale
|
||||
```
|
||||
|
||||
### Preferences (React Context)
|
||||
|
||||
```tsx
|
||||
// Simple boolean preference pattern
|
||||
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
|
||||
|
||||
function SettingsScreen() {
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
const setAutoplayDisabled = useSetAutoplayDisabled()
|
||||
|
||||
return (
|
||||
<Toggle
|
||||
value={autoplayDisabled}
|
||||
onValueChange={setAutoplayDisabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Session State
|
||||
|
||||
```tsx
|
||||
import {useSession, useAgent} from '#/state/session'
|
||||
|
||||
function MyComponent() {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
if (!hasSession) {
|
||||
return <LoginPrompt />
|
||||
}
|
||||
|
||||
// Use agent for API calls
|
||||
const response = await agent.getProfile({actor: currentAccount.did})
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
Navigation uses React Navigation with type-safe route parameters.
|
||||
|
||||
```tsx
|
||||
// Screen component
|
||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
import {type CommonNavigatorParams} from '#/lib/routes/types'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
|
||||
|
||||
export function ProfileScreen({route, navigation}: Props) {
|
||||
const {name} = route.params // Type-safe params
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
{/* Screen content */}
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
// Programmatic navigation
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
const navigation = useNavigation()
|
||||
navigation.navigate('Profile', {name: 'alice.bsky.social'})
|
||||
|
||||
// Or use the navigate helper
|
||||
import {navigate} from '#/Navigation'
|
||||
navigate('Profile', {name: 'alice.bsky.social'})
|
||||
```
|
||||
|
||||
## Platform-Specific Code
|
||||
|
||||
Use file extensions for platform-specific implementations:
|
||||
|
||||
```
|
||||
Component.tsx # Shared/default
|
||||
Component.web.tsx # Web-only
|
||||
Component.native.tsx # iOS + Android
|
||||
Component.ios.tsx # iOS-only
|
||||
Component.android.tsx # Android-only
|
||||
```
|
||||
|
||||
Example from Dialog:
|
||||
- `src/components/Dialog/index.tsx` - Native (uses BottomSheet)
|
||||
- `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives)
|
||||
|
||||
Platform detection:
|
||||
```tsx
|
||||
import {isWeb, isNative, isIOS, isAndroid} from '#/platform/detection'
|
||||
|
||||
if (isNative) {
|
||||
// Native-specific logic
|
||||
}
|
||||
```
|
||||
|
||||
## Import Aliases
|
||||
|
||||
Always use the `#/` alias for absolute imports:
|
||||
|
||||
```tsx
|
||||
// Good
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
|
||||
// Avoid
|
||||
import {useSession} from '../../../state/session'
|
||||
```
|
||||
|
||||
## Footguns
|
||||
|
||||
Common pitfalls to avoid in this codebase:
|
||||
|
||||
### Dialog Close Callback (Critical)
|
||||
|
||||
**Always use `control.close(() => ...)` when performing actions after closing a dialog.** The callback ensures the action runs after the dialog's close animation completes. Failing to do this causes race conditions with React state updates.
|
||||
|
||||
```tsx
|
||||
// WRONG - causes bugs with state updates, navigation, opening other dialogs
|
||||
const onConfirm = () => {
|
||||
control.close()
|
||||
navigation.navigate('Home') // May race with dialog animation
|
||||
}
|
||||
|
||||
// WRONG - same problem
|
||||
const onConfirm = () => {
|
||||
control.close()
|
||||
otherDialogControl.open() // Will likely fail or cause visual glitches
|
||||
}
|
||||
|
||||
// CORRECT - action runs after dialog fully closes
|
||||
const onConfirm = () => {
|
||||
control.close(() => {
|
||||
navigation.navigate('Home')
|
||||
})
|
||||
}
|
||||
|
||||
// CORRECT - opening another dialog after close
|
||||
const onConfirm = () => {
|
||||
control.close(() => {
|
||||
otherDialogControl.open()
|
||||
})
|
||||
}
|
||||
|
||||
// CORRECT - state updates after close
|
||||
const onConfirm = () => {
|
||||
control.close(() => {
|
||||
setSomeState(newValue)
|
||||
onCallback?.()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
This applies to:
|
||||
- Navigation (`navigation.navigate()`, `navigation.push()`)
|
||||
- Opening other dialogs or menus
|
||||
- State updates that affect UI (`setState`, `queryClient.invalidateQueries`)
|
||||
- Callbacks passed from parent components
|
||||
|
||||
The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`.
|
||||
|
||||
### Controlled vs Uncontrolled Inputs
|
||||
|
||||
Prefer `defaultValue` over `value` for TextInput on the old architecture:
|
||||
|
||||
```tsx
|
||||
// Preferred - uncontrolled
|
||||
<TextField.Input
|
||||
defaultValue={initialEmail}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
|
||||
// Avoid when possible - controlled (can cause performance issues)
|
||||
<TextField.Input
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
```
|
||||
|
||||
### Platform-Specific Behavior
|
||||
|
||||
Some components behave differently across platforms:
|
||||
- `Dialog.Handle` - Only renders on native (drag handle for bottom sheet)
|
||||
- `Dialog.Close` - Only renders on web (X button)
|
||||
- `Menu.Divider` - Only renders on web
|
||||
- `Menu.ContainerItem` - Only works on native
|
||||
|
||||
Always test on multiple platforms when using these components.
|
||||
|
||||
### React Compiler is Enabled
|
||||
|
||||
This codebase uses React Compiler, so **don't proactively add `useMemo` or `useCallback`**. The compiler handles memoization automatically.
|
||||
|
||||
```tsx
|
||||
// UNNECESSARY - React Compiler handles this
|
||||
const handlePress = useCallback(() => {
|
||||
doSomething()
|
||||
}, [doSomething])
|
||||
|
||||
// JUST WRITE THIS
|
||||
const handlePress = () => {
|
||||
doSomething()
|
||||
}
|
||||
```
|
||||
|
||||
Only use `useMemo`/`useCallback` when you have a specific reason, such as:
|
||||
- The value is immediately used in an effect's dependency array
|
||||
- You're passing a callback to a non-React library that needs referential stability
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful
|
||||
|
||||
2. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>`
|
||||
|
||||
3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles
|
||||
|
||||
4. **State**: Use TanStack Query for server state, React Context for UI preferences
|
||||
|
||||
5. **Components**: Check if a component exists in `#/components/` before creating new ones
|
||||
|
||||
6. **Types**: Define explicit types for props, use `NativeStackScreenProps` for screens
|
||||
|
||||
7. **Testing**: Components should have `testID` props for E2E testing
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
| Purpose | Location |
|
||||
|---------|----------|
|
||||
| Theme definitions | `src/alf/themes.ts` |
|
||||
| Design tokens | `src/alf/tokens.ts` |
|
||||
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
|
||||
| Navigation config | `src/Navigation.tsx` |
|
||||
| Route definitions | `src/routes.ts` |
|
||||
| Route types | `src/lib/routes/types.ts` |
|
||||
| Query hooks | `src/state/queries/*.ts` |
|
||||
| Session state | `src/state/session/index.tsx` |
|
||||
| i18n setup | `src/locale/i18n.ts` |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -37,14 +37,6 @@
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
html {
|
||||
background-color: white;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
background-color: black;
|
||||
}
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0px;
|
||||
@@ -59,6 +51,19 @@
|
||||
-ms-overflow-style: scrollbar;
|
||||
font-synthesis-weight: none;
|
||||
}
|
||||
:root {
|
||||
--text: black;
|
||||
--background: white;
|
||||
--backgroundLight: #e2e7ee;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: #232e3e;
|
||||
}
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@@ -67,6 +72,32 @@
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
html.theme--light,
|
||||
html.theme--light body,
|
||||
html.theme--light #root {
|
||||
background-color: white;
|
||||
--text: black;
|
||||
--background: white;
|
||||
--backgroundLight: #DCE2EA;
|
||||
}
|
||||
html.theme--dark,
|
||||
html.theme--dark body,
|
||||
html.theme--dark #root {
|
||||
color-scheme: dark;
|
||||
background-color: black;
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: #232E3E;
|
||||
}
|
||||
html.theme--dim,
|
||||
html.theme--dim body,
|
||||
html.theme--dim #root {
|
||||
color-scheme: dark;
|
||||
background-color: #151D28;
|
||||
--text: white;
|
||||
--background: #151D28;
|
||||
--backgroundLight: #2C3A4E;
|
||||
}
|
||||
#splash {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
@@ -93,6 +124,12 @@
|
||||
overflow-y: scroll;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
const theme = localStorage.getItem('ALF_THEME')
|
||||
if (theme) {
|
||||
document.documentElement.classList.add(`theme--${theme}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
{% include "scripts.html" %}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ staticCDNHost }}/static/apple-touch-icon.png">
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"scripts": {
|
||||
"setup": "yarn install",
|
||||
"run": "yarn web --port $CONDUCTOR_PORT"
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.113.0",
|
||||
"version": "1.114.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -73,10 +73,11 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.18.8",
|
||||
"@atproto/api": "^0.18.15",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.6",
|
||||
"@bsky.app/expo-image-crop-tool": "^0.5.0",
|
||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@@ -145,7 +146,6 @@
|
||||
"expo-font": "~14.0.10",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-image-crop-tool": "^0.4.0",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.9",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
@@ -156,6 +156,7 @@
|
||||
"expo-location": "~19.0.8",
|
||||
"expo-media-library": "~18.2.1",
|
||||
"expo-notifications": "~0.32.14",
|
||||
"expo-privacy-sensitive": "^0.1.0",
|
||||
"expo-screen-orientation": "~9.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-sms": "^14.0.7",
|
||||
@@ -165,6 +166,7 @@
|
||||
"expo-updates": "~29.0.14",
|
||||
"expo-video": "~3.0.15",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"history": "^5.3.0",
|
||||
"hls.js": "^1.6.2",
|
||||
@@ -174,7 +176,6 @@
|
||||
"libphonenumber-js": "^1.12.31",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"lodash.shuffle": "^4.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"multiformats": "9.9.0",
|
||||
@@ -222,10 +223,11 @@
|
||||
"tippy.js": "^6.3.7",
|
||||
"tlds": "^1.234.0",
|
||||
"tldts": "^6.1.46",
|
||||
"unicode-segmenter": "^0.14.5",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.3.196",
|
||||
"@atproto/dev-env": "^0.3.204",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/runtime": "^7.26.0",
|
||||
@@ -283,10 +285,12 @@
|
||||
"@react-native/babel-preset": "0.81.5",
|
||||
"@react-native/normalize-colors": "0.81.5",
|
||||
"**/@expo/image-utils": "0.8.7",
|
||||
"**/@react-native-async-storage/async-storage": "2.2.0",
|
||||
"**/expo-constants": "18.0.8",
|
||||
"**/expo-device": "7.1.4",
|
||||
"**/zod": "3.23.8",
|
||||
"**/multiformats": "9.9.0"
|
||||
"**/multiformats": "9.9.0",
|
||||
"unicode-segmenter": "0.14.5"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "jest-expo/ios",
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
diff --git a/node_modules/@haileyok/bluesky-video/android/build.gradle b/node_modules/@haileyok/bluesky-video/android/build.gradle
|
||||
index b988d3f..7743421 100644
|
||||
--- a/node_modules/@haileyok/bluesky-video/android/build.gradle
|
||||
+++ b/node_modules/@haileyok/bluesky-video/android/build.gradle
|
||||
@@ -36,6 +36,7 @@ android {
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
+ consumerProguardFiles 'proguard-rules.pro'
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
diff --git a/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro b/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro
|
||||
new file mode 100644
|
||||
index 0000000..3b5b864
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro
|
||||
@@ -0,0 +1,2 @@
|
||||
+# Keep FullscreenActivity from being stripped by R8/ProGuard
|
||||
+-keep class expo.modules.blueskyvideo.FullscreenActivity { *; }
|
||||
diff --git a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
|
||||
index fdabd84..eda8c7c 100644
|
||||
--- a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
|
||||
+++ b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
|
||||
@@ -1,8 +1,11 @@
|
||||
package expo.modules.blueskyvideo
|
||||
|
||||
+import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
+import android.os.Build
|
||||
+import android.util.Log
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.view.ViewGroup
|
||||
@@ -237,9 +240,44 @@ class BlueskyVideoView(
|
||||
// Fullscreen handling
|
||||
|
||||
fun enterFullscreen(keepDisplayOn: Boolean) {
|
||||
- val currentActivity = this.appContext.currentActivity ?: return
|
||||
+ val tag = "BlueskyVideo"
|
||||
+
|
||||
+ Log.d(tag, "enterFullscreen() called - keepDisplayOn=$keepDisplayOn")
|
||||
+ Log.d(tag, " isFullscreen=$isFullscreen, isPlaying=$isPlaying, isMuted=$isMuted")
|
||||
+ Log.d(tag, " player=${player != null}, url=$url")
|
||||
+ Log.d(tag, " isAttachedToWindow=$isAttachedToWindow, isShown=$isShown")
|
||||
+ Log.d(tag, " Android SDK: ${Build.VERSION.SDK_INT}, Device: ${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
+
|
||||
+ val currentActivity = this.appContext.currentActivity
|
||||
+ if (currentActivity == null) {
|
||||
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is null")
|
||||
+ Log.e(tag, " appContext=$appContext")
|
||||
+ onError(mapOf("error" to "Cannot enter fullscreen: no current activity"))
|
||||
+ return
|
||||
+ }
|
||||
+
|
||||
+ Log.d(tag, " currentActivity=$currentActivity")
|
||||
+ Log.d(tag, " activity.isFinishing=${currentActivity.isFinishing}")
|
||||
+ Log.d(tag, " activity.isDestroyed=${currentActivity.isDestroyed}")
|
||||
+ Log.d(tag, " activity.lifecycle=${(currentActivity as? androidx.lifecycle.LifecycleOwner)?.lifecycle?.currentState}")
|
||||
+ Log.d(tag, " activity.hasWindowFocus=${currentActivity.hasWindowFocus()}")
|
||||
+ Log.d(tag, " activity.window.isActive=${currentActivity.window?.isActive}")
|
||||
+
|
||||
+ // Check if activity is in a valid state to start another activity
|
||||
+ if (currentActivity.isFinishing) {
|
||||
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is finishing")
|
||||
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is finishing"))
|
||||
+ return
|
||||
+ }
|
||||
+
|
||||
+ if (currentActivity.isDestroyed) {
|
||||
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is destroyed")
|
||||
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is destroyed"))
|
||||
+ return
|
||||
+ }
|
||||
|
||||
this.enteredFullscreenMuteState = this.isMuted
|
||||
+ Log.d(tag, " saved enteredFullscreenMuteState=$enteredFullscreenMuteState")
|
||||
|
||||
// We always want to start with unmuted state and playing. Fire those from here so the
|
||||
// event dispatcher gets called
|
||||
@@ -247,18 +285,51 @@ class BlueskyVideoView(
|
||||
if (!this.isPlaying) {
|
||||
this.play()
|
||||
}
|
||||
+ Log.d(tag, " after unmute/play: isPlaying=$isPlaying, isMuted=$isMuted")
|
||||
|
||||
// Remove the player from this view, but don't null the player!
|
||||
this.playerView.player = null
|
||||
+ Log.d(tag, " detached player from playerView")
|
||||
|
||||
// create the intent and give it a view
|
||||
val intent = Intent(context, FullscreenActivity::class.java)
|
||||
intent.putExtra("keepDisplayOn", keepDisplayOn)
|
||||
FullscreenActivity.asscVideoView = WeakReference(this)
|
||||
|
||||
+ Log.d(tag, " intent created: $intent")
|
||||
+ Log.d(tag, " intent.component=${intent.component}")
|
||||
+ Log.d(tag, " intent.flags=${intent.flags} (0x${Integer.toHexString(intent.flags)})")
|
||||
+ Log.d(tag, " context for intent=$context")
|
||||
+ Log.d(tag, " FullscreenActivity.asscVideoView set to WeakReference(this)")
|
||||
+
|
||||
// fire the fullscreen event and launch the intent
|
||||
- this.isFullscreen = true
|
||||
- currentActivity.startActivity(intent)
|
||||
+ try {
|
||||
+ Log.d(tag, " calling startActivity()...")
|
||||
+ currentActivity.startActivity(intent)
|
||||
+ this.isFullscreen = true
|
||||
+ Log.d(tag, " startActivity() SUCCESS - isFullscreen set to true")
|
||||
+ } catch (e: Exception) {
|
||||
+ Log.e(tag, "enterFullscreen() FAILED: startActivity() threw exception", e)
|
||||
+ Log.e(tag, " exception class: ${e.javaClass.name}")
|
||||
+ Log.e(tag, " exception message: ${e.message}")
|
||||
+ Log.e(tag, " exception cause: ${e.cause}")
|
||||
+ e.printStackTrace()
|
||||
+
|
||||
+ // Restore state since fullscreen failed
|
||||
+ this.playerView.player = this.player
|
||||
+ Log.d(tag, " restored player to playerView after failure")
|
||||
+
|
||||
+ if (this.enteredFullscreenMuteState) {
|
||||
+ this.mute()
|
||||
+ Log.d(tag, " restored mute state after failure")
|
||||
+ }
|
||||
+
|
||||
+ onError(mapOf(
|
||||
+ "error" to "Failed to enter fullscreen: ${e.message}",
|
||||
+ "exceptionClass" to e.javaClass.name,
|
||||
+ "exceptionMessage" to (e.message ?: "unknown")
|
||||
+ ))
|
||||
+ }
|
||||
}
|
||||
|
||||
fun onExitFullscreen() {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
diff --git a/node_modules/expo-image/build/Image.types.d.ts b/node_modules/expo-image/build/Image.types.d.ts
|
||||
index 022ae48..416504f 100644
|
||||
--- a/node_modules/expo-image/build/Image.types.d.ts
|
||||
+++ b/node_modules/expo-image/build/Image.types.d.ts
|
||||
@@ -152,6 +152,16 @@ export interface ImageProps extends Omit<ViewProps, 'style' | 'children'> {
|
||||
* @default 'normal'
|
||||
*/
|
||||
priority?: 'low' | 'normal' | 'high' | null;
|
||||
+ /**
|
||||
+ * The loading behavior for the image. Maps to the native HTML `loading` attribute on web.
|
||||
+ *
|
||||
+ * - `'lazy'` - Defers loading until the image is near the viewport.
|
||||
+ * - `'eager'` - Loads the image immediately.
|
||||
+ *
|
||||
+ * @default undefined
|
||||
+ * @platform web
|
||||
+ */
|
||||
+ loading?: 'lazy' | 'eager' | null;
|
||||
/**
|
||||
* Determines whether to cache the image and where: on the disk, in the memory or both.
|
||||
*
|
||||
diff --git a/node_modules/expo-image/src/ExpoImage.web.tsx b/node_modules/expo-image/src/ExpoImage.web.tsx
|
||||
index 2a49ff0..1c3de93 100644
|
||||
--- a/node_modules/expo-image/src/ExpoImage.web.tsx
|
||||
+++ b/node_modules/expo-image/src/ExpoImage.web.tsx
|
||||
@@ -70,6 +70,7 @@ export default function ExpoImage({
|
||||
onLoadEnd,
|
||||
onDisplay,
|
||||
priority,
|
||||
+ loading,
|
||||
blurRadius,
|
||||
recyclingKey,
|
||||
style,
|
||||
@@ -118,6 +119,7 @@ export default function ExpoImage({
|
||||
accessibilityLabel={accessibilityLabel ?? alt}
|
||||
cachePolicy={cachePolicy}
|
||||
priority={priority}
|
||||
+ loading={loading}
|
||||
tintColor={tintColor}
|
||||
/>
|
||||
),
|
||||
@@ -149,6 +151,7 @@ export default function ExpoImage({
|
||||
className={className}
|
||||
cachePolicy={cachePolicy}
|
||||
priority={priority}
|
||||
+ loading={loading}
|
||||
contentPosition={selectedSource ? contentPosition : { top: '50%', left: '50%' }}
|
||||
hashPlaceholderContentPosition={contentPosition}
|
||||
hashPlaceholderStyle={imageHashStyle}
|
||||
diff --git a/node_modules/expo-image/src/Image.types.ts b/node_modules/expo-image/src/Image.types.ts
|
||||
index 9dec0e7..61c1621 100644
|
||||
--- a/node_modules/expo-image/src/Image.types.ts
|
||||
+++ b/node_modules/expo-image/src/Image.types.ts
|
||||
@@ -178,6 +178,17 @@ export interface ImageProps extends Omit<ViewProps, 'style' | 'children'> {
|
||||
*/
|
||||
priority?: 'low' | 'normal' | 'high' | null;
|
||||
|
||||
+ /**
|
||||
+ * The loading behavior for the image. Maps to the native HTML `loading` attribute on web.
|
||||
+ *
|
||||
+ * - `'lazy'` - Defers loading until the image is near the viewport.
|
||||
+ * - `'eager'` - Loads the image immediately.
|
||||
+ *
|
||||
+ * @default undefined
|
||||
+ * @platform web
|
||||
+ */
|
||||
+ loading?: 'lazy' | 'eager' | null;
|
||||
+
|
||||
/**
|
||||
* Determines whether to cache the image and where: on the disk, in the memory or both.
|
||||
*
|
||||
diff --git a/node_modules/expo-image/src/web/ImageWrapper.tsx b/node_modules/expo-image/src/web/ImageWrapper.tsx
|
||||
index e8f891d..89a5cb1 100644
|
||||
--- a/node_modules/expo-image/src/web/ImageWrapper.tsx
|
||||
+++ b/node_modules/expo-image/src/web/ImageWrapper.tsx
|
||||
@@ -30,6 +30,7 @@ const ImageWrapper = React.forwardRef(
|
||||
contentPosition,
|
||||
hashPlaceholderContentPosition,
|
||||
priority,
|
||||
+ loading,
|
||||
style,
|
||||
hashPlaceholderStyle,
|
||||
tintColor,
|
||||
@@ -82,6 +83,7 @@ const ImageWrapper = React.forwardRef(
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
fetchPriority={getFetchPriorityFromImagePriority(priority || 'normal')}
|
||||
+ loading={loading || undefined}
|
||||
{...getImageWrapperEventHandler(events, sourceWithHeaders)}
|
||||
{...getImgPropsFromSource(source)}
|
||||
{...props}
|
||||
diff --git a/node_modules/expo-image/src/web/ImageWrapper.types.ts b/node_modules/expo-image/src/web/ImageWrapper.types.ts
|
||||
index 19bbe2f..179837f 100644
|
||||
--- a/node_modules/expo-image/src/web/ImageWrapper.types.ts
|
||||
+++ b/node_modules/expo-image/src/web/ImageWrapper.types.ts
|
||||
@@ -29,6 +29,7 @@ export type ImageWrapperProps = {
|
||||
contentPosition?: ImageContentPositionObject;
|
||||
hashPlaceholderContentPosition?: ImageContentPositionObject;
|
||||
priority?: string | null;
|
||||
+ loading?: 'lazy' | 'eager' | null;
|
||||
style: CSSProperties;
|
||||
tintColor?: string | null;
|
||||
hashPlaceholderStyle?: CSSProperties;
|
||||
@@ -0,0 +1,30 @@
|
||||
diff --git a/node_modules/react-native-pager-view/ios/RNCPagerView.m b/node_modules/react-native-pager-view/ios/RNCPagerView.m
|
||||
index adfc7c6..366df60 100644
|
||||
--- a/node_modules/react-native-pager-view/ios/RNCPagerView.m
|
||||
+++ b/node_modules/react-native-pager-view/ios/RNCPagerView.m
|
||||
@@ -498,6 +498,25 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecogni
|
||||
return YES;
|
||||
}
|
||||
|
||||
+ // iOS 26+ full-screen back gesture (interactiveContentPopGestureRecognizer)
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ if (gestureRecognizer == self.panGestureRecognizer &&
|
||||
+ otherGestureRecognizer == self.reactViewController.navigationController.interactiveContentPopGestureRecognizer) {
|
||||
+ UIPanGestureRecognizer* panGestureRecognizer = (UIPanGestureRecognizer*) gestureRecognizer;
|
||||
+ CGPoint velocity = [panGestureRecognizer velocityInView:self];
|
||||
+ BOOL isLTR = [self isLtrLayout];
|
||||
+ BOOL isBackGesture = (isLTR && velocity.x > 0) || (!isLTR && velocity.x < 0);
|
||||
+
|
||||
+ if (self.currentIndex == 0 && isBackGesture) {
|
||||
+ self.scrollView.panGestureRecognizer.enabled = false;
|
||||
+ } else {
|
||||
+ self.scrollView.panGestureRecognizer.enabled = self.scrollEnabled;
|
||||
+ }
|
||||
+
|
||||
+ return YES;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
self.scrollView.panGestureRecognizer.enabled = self.scrollEnabled;
|
||||
return NO;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# react-native-pager-view+6.8.0.patch
|
||||
|
||||
Adds support for iOS 26's `interactiveContentPopGestureRecognizer` (full-screen back gesture).
|
||||
|
||||
The pager already handles `RNSPanGestureRecognizer` (react-native-screens' custom full-screen gesture for pre-iOS 26) in `shouldRecognizeSimultaneouslyWithGestureRecognizer:`. It checks if the user is on the leftmost page and swiping right - if so, it disables the scrollview's pan gesture to let the back gesture through.
|
||||
|
||||
This patch adds the same logic for iOS 26's native `interactiveContentPopGestureRecognizer`, so the back gesture works on the leftmost page while the pager still handles swipes on other pages.
|
||||
|
||||
Related issues:
|
||||
- https://github.com/software-mansion/react-native-screens/issues/3512
|
||||
- https://github.com/software-mansion/react-native-screens/pull/3420
|
||||
+50
-43
@@ -69,6 +69,10 @@ import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbe
|
||||
import {ToastOutlet} from '#/components/Toast'
|
||||
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
|
||||
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
|
||||
import {
|
||||
prefetchLiveEvents,
|
||||
Provider as LiveEventsProvider,
|
||||
} from '#/features/liveEvents/context'
|
||||
import * as Geo from '#/geolocation'
|
||||
import {Splash} from '#/Splash'
|
||||
import {BottomSheetProvider} from '../modules/bottom-sheet'
|
||||
@@ -92,6 +96,7 @@ if (isAndroid) {
|
||||
*/
|
||||
Geo.resolve()
|
||||
prefetchAgeAssuranceConfig()
|
||||
prefetchLiveEvents()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
@@ -141,49 +146,51 @@ function InnerApp() {
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceAccountManager>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<GlobalGestureEventsProvider>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</GlobalGestureEventsProvider>
|
||||
</GestureHandlerRootView>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceAccountManager>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
<LiveEventsProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceAccountManager>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<GlobalGestureEventsProvider>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</GlobalGestureEventsProvider>
|
||||
</GestureHandlerRootView>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceAccountManager>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
</LiveEventsProvider>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
|
||||
+46
-39
@@ -57,6 +57,10 @@ import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbe
|
||||
import {ToastOutlet} from '#/components/Toast'
|
||||
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
|
||||
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
|
||||
import {
|
||||
prefetchLiveEvents,
|
||||
Provider as LiveEventsProvider,
|
||||
} from '#/features/liveEvents/context'
|
||||
import * as Geo from '#/geolocation'
|
||||
import {Splash} from '#/Splash'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
@@ -67,6 +71,7 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
|
||||
*/
|
||||
Geo.resolve()
|
||||
prefetchAgeAssuranceConfig()
|
||||
prefetchLiveEvents()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
@@ -117,45 +122,47 @@ function InnerApp() {
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceConfigProvider>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<IntentDialogProvider>
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
<LiveEventsProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceConfigProvider>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<IntentDialogProvider>
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
</LiveEventsProvider>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
|
||||
+13
-5
@@ -5,7 +5,6 @@ import {i18n, type MessageDescriptor} from '@lingui/core'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {
|
||||
type BottomTabBarProps,
|
||||
type BottomTabNavigatorProps,
|
||||
createBottomTabNavigator,
|
||||
} from '@react-navigation/bottom-tabs'
|
||||
import {
|
||||
@@ -69,7 +68,7 @@ import {PostThreadScreen} from '#/view/screens/PostThread'
|
||||
import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy'
|
||||
import {ProfileScreen} from '#/view/screens/Profile'
|
||||
import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy'
|
||||
import {Storybook} from '#/view/screens/Storybook'
|
||||
import {StorybookScreen} from '#/view/screens/Storybook'
|
||||
import {SupportScreen} from '#/view/screens/Support'
|
||||
import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
|
||||
import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
|
||||
@@ -305,7 +304,7 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Debug"
|
||||
getComponent={() => Storybook}
|
||||
getComponent={() => StorybookScreen}
|
||||
options={{title: title(msg`Storybook`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -637,7 +636,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
* The TabsNavigator is used by native mobile to represent the routes
|
||||
* in 3 distinct tab-stacks with a different root screen on each.
|
||||
*/
|
||||
function TabsNavigator({layout}: {layout: BottomTabNavigatorProps['layout']}) {
|
||||
function TabsNavigator({
|
||||
layout,
|
||||
}: {
|
||||
layout: React.ComponentProps<typeof Tab.Navigator>['layout']
|
||||
}) {
|
||||
const tabBar = useCallback(
|
||||
(props: JSX.IntrinsicAttributes & BottomTabBarProps) => (
|
||||
<BottomBar {...props} />
|
||||
@@ -759,7 +762,11 @@ function MessagesTabNavigator() {
|
||||
* The FlatNavigator is used by Web to represent the routes
|
||||
* in a single ("flat") stack.
|
||||
*/
|
||||
const FlatNavigator = () => {
|
||||
const FlatNavigator = ({
|
||||
layout,
|
||||
}: {
|
||||
layout: React.ComponentProps<typeof Flat.Navigator>['layout']
|
||||
}) => {
|
||||
const t = useTheme()
|
||||
const numUnread = useUnreadNotifications()
|
||||
const screenListeners = useWebScrollRestoration()
|
||||
@@ -767,6 +774,7 @@ const FlatNavigator = () => {
|
||||
|
||||
return (
|
||||
<Flat.Navigator
|
||||
layout={layout}
|
||||
screenListeners={screenListeners}
|
||||
screenOptions={screenOptions(t)}>
|
||||
<Flat.Screen
|
||||
|
||||
@@ -177,10 +177,19 @@ export function NoAccessScreen() {
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{!aa.flags.isOverRegionMinAccessAge && (
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Unfortunately, your declared age indicates that you
|
||||
are not old enough to access Bluesky in your region.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!isBlocked && birthdateUpdateText}
|
||||
</View>
|
||||
|
||||
<AccessSection />
|
||||
{aa.flags.isOverRegionMinAccessAge && <AccessSection />}
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
|
||||
@@ -136,6 +136,15 @@ export async function prefetchConfig() {
|
||||
}
|
||||
})
|
||||
}
|
||||
export async function refetchConfig() {
|
||||
logger.debug(`refetchConfig: fetching...`)
|
||||
const res = await getConfig()
|
||||
qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
|
||||
configQueryKey,
|
||||
res,
|
||||
)
|
||||
return res
|
||||
}
|
||||
export function useConfigQuery() {
|
||||
return useQuery(
|
||||
{
|
||||
@@ -146,6 +155,10 @@ export function useConfigQuery() {
|
||||
* @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat
|
||||
*/
|
||||
staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60,
|
||||
/**
|
||||
* N.B. if prefetch failed above, we'll have no `initialData`, and this
|
||||
* query will run on startup.
|
||||
*/
|
||||
initialData: getConfigFromCache(),
|
||||
initialDataUpdatedAt: () =>
|
||||
qc.getQueryState(configQueryKey)?.dataUpdatedAt,
|
||||
|
||||
@@ -17,18 +17,32 @@ export const geolocation: Geolocation | undefined = enabled
|
||||
}
|
||||
: undefined
|
||||
|
||||
export const deviceGeolocation: Geolocation | undefined = enabled
|
||||
? {
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
}
|
||||
: undefined
|
||||
const deviceGeolocationEnabled = false || IS_E2E
|
||||
export const deviceGeolocation: Geolocation | undefined =
|
||||
enabled && deviceGeolocationEnabled
|
||||
? {
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
}
|
||||
: undefined
|
||||
|
||||
export const config: AppBskyAgeassuranceDefs.Config = {
|
||||
regions: [
|
||||
{
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
minAccessAge: 13,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.Default,
|
||||
access: 'full',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'BB',
|
||||
regionCode: undefined,
|
||||
minAccessAge: 16,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.Default,
|
||||
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
type AgeAssuranceState,
|
||||
AgeAssuranceStatus,
|
||||
} from '#/ageAssurance/types'
|
||||
import {isUserUnderAdultAge} from '#/ageAssurance/util'
|
||||
import {
|
||||
isUnderAge,
|
||||
MIN_ACCESS_AGE,
|
||||
useAgeAssuranceRegionConfigWithFallback,
|
||||
} from '#/ageAssurance/util'
|
||||
|
||||
export {
|
||||
prefetchConfig as prefetchAgeAssuranceConfig,
|
||||
@@ -24,6 +28,7 @@ export {
|
||||
usePatchServerState as usePatchAgeAssuranceServerState,
|
||||
} from '#/ageAssurance/data'
|
||||
export {logger} from '#/ageAssurance/logger'
|
||||
export {MIN_ACCESS_AGE} from '#/ageAssurance/util'
|
||||
|
||||
const AgeAssuranceStateContext = createContext<{
|
||||
Access: typeof AgeAssuranceAccess
|
||||
@@ -32,6 +37,8 @@ const AgeAssuranceStateContext = createContext<{
|
||||
flags: {
|
||||
adultContentDisabled: boolean
|
||||
chatDisabled: boolean
|
||||
isOverRegionMinAccessAge: boolean
|
||||
isOverAppMinAccessAge: boolean
|
||||
}
|
||||
}>({
|
||||
Access: AgeAssuranceAccess,
|
||||
@@ -44,6 +51,8 @@ const AgeAssuranceStateContext = createContext<{
|
||||
flags: {
|
||||
adultContentDisabled: false,
|
||||
chatDisabled: false,
|
||||
isOverRegionMinAccessAge: false,
|
||||
isOverAppMinAccessAge: false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -69,6 +78,7 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
function InnerProvider({children}: {children: React.ReactNode}) {
|
||||
const state = useAgeAssuranceState()
|
||||
const {data} = useAgeAssuranceDataContext()
|
||||
const config = useAgeAssuranceRegionConfigWithFallback()
|
||||
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
||||
|
||||
const handleAccessUpdate = useCallback(
|
||||
@@ -89,11 +99,17 @@ function InnerProvider({children}: {children: React.ReactNode}) {
|
||||
<AgeAssuranceStateContext.Provider
|
||||
value={useMemo(() => {
|
||||
const chatDisabled = state.access !== AgeAssuranceAccess.Full
|
||||
const isUnderage = data?.birthdate
|
||||
? isUserUnderAdultAge(data.birthdate)
|
||||
const isUnderAdultAge = data?.birthdate
|
||||
? isUnderAge(data.birthdate, 18)
|
||||
: true
|
||||
const isOverRegionMinAccessAge = data?.birthdate
|
||||
? !isUnderAge(data.birthdate, config.minAccessAge)
|
||||
: false
|
||||
const isOverAppMinAccessAge = data?.birthdate
|
||||
? !isUnderAge(data.birthdate, MIN_ACCESS_AGE)
|
||||
: false
|
||||
const adultContentDisabled =
|
||||
state.access !== AgeAssuranceAccess.Full || isUnderage
|
||||
state.access !== AgeAssuranceAccess.Full || isUnderAdultAge
|
||||
return {
|
||||
Access: AgeAssuranceAccess,
|
||||
Status: AgeAssuranceStatus,
|
||||
@@ -101,9 +117,11 @@ function InnerProvider({children}: {children: React.ReactNode}) {
|
||||
flags: {
|
||||
adultContentDisabled,
|
||||
chatDisabled,
|
||||
isOverRegionMinAccessAge,
|
||||
isOverAppMinAccessAge,
|
||||
},
|
||||
}
|
||||
}, [state, data])}>
|
||||
}, [state, data, config])}>
|
||||
{children}
|
||||
</AgeAssuranceStateContext.Provider>
|
||||
)
|
||||
|
||||
@@ -30,12 +30,19 @@ export function useAgeAssuranceState(): AgeAssuranceState {
|
||||
access: AgeAssuranceAccess.Safe,
|
||||
}
|
||||
|
||||
// should never happen, but need to guard
|
||||
/**
|
||||
* This can happen if the prefetch fails (such as due to network issues).
|
||||
* The query handler will try it again, but if it continues to fail, of
|
||||
* course we won't have config.
|
||||
*
|
||||
* In this case, fail open to avoid blocking users.
|
||||
*/
|
||||
if (!config) {
|
||||
logger.warn('useAgeAssuranceState: missing config')
|
||||
return {
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.Unknown,
|
||||
access: AgeAssuranceAccess.Safe,
|
||||
error: 'config',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export type AgeAssuranceState = {
|
||||
lastInitiatedAt?: string
|
||||
status: AgeAssuranceStatus
|
||||
access: AgeAssuranceAccess
|
||||
error?: 'config' // maybe other specific cases in the future
|
||||
}
|
||||
|
||||
export function parseStatusFromString(raw: string) {
|
||||
|
||||
+30
-26
@@ -12,7 +12,23 @@ import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
import {AgeAssuranceAccess} from '#/ageAssurance/types'
|
||||
import {type Geolocation, useGeolocation} from '#/geolocation'
|
||||
|
||||
const DEFAULT_MIN_AGE = 13
|
||||
export const MIN_ACCESS_AGE = 13
|
||||
const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
|
||||
countryCode: '*',
|
||||
regionCode: undefined,
|
||||
minAccessAge: MIN_ACCESS_AGE,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
age: MIN_ACCESS_AGE,
|
||||
access: AgeAssuranceAccess.Full,
|
||||
},
|
||||
{
|
||||
$type: ids.Default,
|
||||
access: AgeAssuranceAccess.None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Get age assurance region config based on geolocation, with fallback to
|
||||
@@ -30,23 +46,7 @@ export function getAgeAssuranceRegionConfigWithFallback(
|
||||
regionCode: geolocation.regionCode,
|
||||
})
|
||||
|
||||
return (
|
||||
region || {
|
||||
countryCode: '*',
|
||||
regionCode: undefined,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
age: DEFAULT_MIN_AGE,
|
||||
access: AgeAssuranceAccess.Full,
|
||||
},
|
||||
{
|
||||
$type: ids.Default,
|
||||
access: AgeAssuranceAccess.None,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
return region || FALLBACK_REGION_CONFIG
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,6 +67,14 @@ export function useAgeAssuranceRegionConfig() {
|
||||
}, [config, geolocation])
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the age assurance region config based on current geolocation.
|
||||
* Falls back to our app defaults if no region config is found.
|
||||
*/
|
||||
export function useAgeAssuranceRegionConfigWithFallback() {
|
||||
return useAgeAssuranceRegionConfig() || FALLBACK_REGION_CONFIG
|
||||
}
|
||||
|
||||
/**
|
||||
* Some users may have erroneously set their birth date to the current date
|
||||
* if one wasn't set on their account. We previously didn't do validation on
|
||||
@@ -78,15 +86,11 @@ export function isLegacyBirthdateBug(birthDate: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the user is under the minimum age required to use the app.
|
||||
* This applies to all regions.
|
||||
* Returns whether the date (converted to an age as a whole integer) is under
|
||||
* the provided minimum age.
|
||||
*/
|
||||
export function isUserUnderMinimumAge(birthDate: string) {
|
||||
return getAge(new Date(birthDate)) < DEFAULT_MIN_AGE
|
||||
}
|
||||
|
||||
export function isUserUnderAdultAge(birthDate: string) {
|
||||
return getAge(new Date(birthDate)) < 18
|
||||
export function isUnderAge(birthDate: string, age: number) {
|
||||
return getAge(new Date(birthDate)) < age
|
||||
}
|
||||
|
||||
export function getBirthdateStringFromAge(age: number) {
|
||||
|
||||
+6
-1
@@ -11,7 +11,12 @@ import {
|
||||
import {themes} from '#/alf/themes'
|
||||
import {type Device} from '#/storage'
|
||||
|
||||
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
|
||||
export {
|
||||
type TextStyleProp,
|
||||
type Theme,
|
||||
utils,
|
||||
type ViewStyleProp,
|
||||
} from '@bsky.app/alf'
|
||||
export {atoms} from '#/alf/atoms'
|
||||
export * from '#/alf/breakpoints'
|
||||
export * from '#/alf/fonts'
|
||||
|
||||
@@ -51,6 +51,7 @@ function updateDocument(theme: ThemeName) {
|
||||
html.classList.add(`theme--${theme}`)
|
||||
// set color to 'theme-color' meta tag
|
||||
meta?.setAttribute('content', getBackgroundColor(theme))
|
||||
window.localStorage.setItem('ALF_THEME', theme)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -359,7 +359,13 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Handle({difference = false}: {difference?: boolean}) {
|
||||
export function Handle({
|
||||
difference = false,
|
||||
fill,
|
||||
}: {
|
||||
difference?: boolean
|
||||
fill?: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {screenReaderEnabled} = useA11y()
|
||||
@@ -390,7 +396,7 @@ export function Handle({difference = false}: {difference?: boolean}) {
|
||||
opacity: 0.75,
|
||||
}
|
||||
: {
|
||||
backgroundColor: t.palette.contrast_975,
|
||||
backgroundColor: fill || t.palette.contrast_975,
|
||||
opacity: 0.5,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, {useMemo} from 'react'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
@@ -21,19 +21,21 @@ import {
|
||||
import {useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, select, useTheme} from '#/alf'
|
||||
import {
|
||||
Button,
|
||||
ButtonIcon,
|
||||
type ButtonProps,
|
||||
ButtonText,
|
||||
} from '#/components/Button'
|
||||
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
|
||||
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
|
||||
import {Link as InternalLink, type LinkProps} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText, type RichTextProps} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useActiveLiveEventFeedUris} from '#/features/liveEvents/context'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash'
|
||||
|
||||
@@ -49,7 +51,11 @@ export function Default(props: Props) {
|
||||
<Outer>
|
||||
<Header>
|
||||
<Avatar src={view.avatar} />
|
||||
<TitleAndByline title={view.displayName} creator={view.creator} />
|
||||
<TitleAndByline
|
||||
title={view.displayName}
|
||||
creator={view.creator}
|
||||
uri={view.uri}
|
||||
/>
|
||||
<SaveButton view={view} pin />
|
||||
</Header>
|
||||
<Description description={view.description} />
|
||||
@@ -118,14 +124,40 @@ export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
|
||||
export function TitleAndByline({
|
||||
title,
|
||||
creator,
|
||||
uri,
|
||||
}: {
|
||||
title: string
|
||||
creator?: bsky.profile.AnyProfileView
|
||||
uri?: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const activeLiveEvents = useActiveLiveEventFeedUris()
|
||||
const liveColor = useMemo(
|
||||
() =>
|
||||
select(t.name, {
|
||||
dark: t.palette.negative_600,
|
||||
dim: t.palette.negative_600,
|
||||
light: t.palette.negative_500,
|
||||
}),
|
||||
[t],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
{uri && activeLiveEvents.has(uri) && (
|
||||
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
|
||||
<LiveIcon size="xs" fill={liveColor} />
|
||||
<Text
|
||||
style={[
|
||||
a.text_2xs,
|
||||
a.font_medium,
|
||||
a.leading_snug,
|
||||
{color: liveColor},
|
||||
]}>
|
||||
<Trans>Happening now</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text
|
||||
emoji
|
||||
style={[a.text_md, a.font_semi_bold, a.leading_snug]}
|
||||
|
||||
@@ -56,6 +56,7 @@ function CardOuter({
|
||||
const {gtMobile} = useBreakpoints()
|
||||
return (
|
||||
<View
|
||||
testID="CardOuter"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.w_full,
|
||||
@@ -575,7 +576,8 @@ export function ProfileGrid({
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}}>
|
||||
}}
|
||||
style={[a.flex_1]}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
style={[
|
||||
@@ -840,6 +842,7 @@ export function SuggestedFeeds() {
|
||||
<FeedCard.TitleAndByline
|
||||
title={feed.displayName}
|
||||
creator={feed.creator}
|
||||
uri={feed.uri}
|
||||
/>
|
||||
</FeedCard.Header>
|
||||
<FeedCard.Description
|
||||
@@ -931,8 +934,15 @@ export function SuggestedFeeds() {
|
||||
|
||||
export function ProgressGuide() {
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
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 />
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -167,6 +167,7 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
||||
a.gap_sm,
|
||||
a.px_md,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_low,
|
||||
@@ -193,7 +194,6 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
a.text_md,
|
||||
a.font_semi_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
{paddingTop: 3},
|
||||
style,
|
||||
disabled && t.atoms.text_contrast_low,
|
||||
]}>
|
||||
@@ -202,16 +202,18 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemIcon({icon: Comp}: ItemIconProps) {
|
||||
export function ItemIcon({icon: Comp, fill}: ItemIconProps) {
|
||||
const t = useTheme()
|
||||
const {disabled} = useMenuItemContext()
|
||||
return (
|
||||
<Comp
|
||||
size="lg"
|
||||
fill={
|
||||
disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
fill
|
||||
? fill({disabled})
|
||||
: disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -262,6 +262,7 @@ export function Item({children, label, onPress, style, ...rest}: ItemProps) {
|
||||
a.gap_lg,
|
||||
a.py_sm,
|
||||
a.rounded_xs,
|
||||
a.overflow_hidden,
|
||||
{minHeight: 32, paddingHorizontal: 10},
|
||||
web({outline: 0}),
|
||||
(hovered || focused) &&
|
||||
@@ -302,7 +303,7 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
|
||||
export function ItemIcon({icon: Comp, position = 'left', fill}: ItemIconProps) {
|
||||
const t = useTheme()
|
||||
const {disabled} = useMenuItemContext()
|
||||
return (
|
||||
@@ -319,9 +320,11 @@ export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
|
||||
<Comp
|
||||
size="md"
|
||||
fill={
|
||||
disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
fill
|
||||
? fill({disabled})
|
||||
: disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -107,6 +107,7 @@ export type ItemTextProps = React.PropsWithChildren<TextStyleProp & {}>
|
||||
export type ItemIconProps = React.PropsWithChildren<{
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
position?: 'left' | 'right'
|
||||
fill?: (props: {disabled: boolean}) => string
|
||||
}>
|
||||
|
||||
export type GroupProps = React.PropsWithChildren<ViewStyleProp & {}>
|
||||
|
||||
@@ -226,6 +226,7 @@ export function ExternalPlayer({
|
||||
style={[a.flex_1]}
|
||||
source={{uri: link.thumb}}
|
||||
accessibilityIgnoresInvertColors
|
||||
loading="lazy"
|
||||
/>
|
||||
<Fill
|
||||
style={[
|
||||
|
||||
@@ -100,6 +100,7 @@ export const ExternalEmbed = ({
|
||||
style={[a.aspect_card]}
|
||||
source={{uri: imageUri}}
|
||||
accessibilityIgnoresInvertColors
|
||||
loading="lazy"
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
|
||||
@@ -17,16 +17,16 @@ export function FeedEmbed({
|
||||
return (
|
||||
<FeedCard.Link
|
||||
view={embed.view}
|
||||
style={[a.border, t.atoms.border_contrast_low, a.p_md, a.rounded_sm]}>
|
||||
style={[a.border, t.atoms.border_contrast_low, a.p_sm, a.rounded_md]}>
|
||||
<FeedCard.Outer>
|
||||
<FeedCard.Header>
|
||||
<FeedCard.Avatar src={embed.view.avatar} />
|
||||
<FeedCard.Avatar src={embed.view.avatar} size={48} />
|
||||
<FeedCard.TitleAndByline
|
||||
title={embed.view.displayName}
|
||||
creator={embed.view.creator}
|
||||
uri={embed.view.uri}
|
||||
/>
|
||||
</FeedCard.Header>
|
||||
<FeedCard.Likes count={embed.view.likeCount || 0} />
|
||||
</FeedCard.Outer>
|
||||
</FeedCard.Link>
|
||||
)
|
||||
|
||||
@@ -10,9 +10,9 @@ import {Image} from 'expo-image'
|
||||
|
||||
import {useLightboxControls} from '#/state/lightbox'
|
||||
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
|
||||
import {AutoSizedImage} from '#/view/com/util/images/AutoSizedImage'
|
||||
import {ImageLayoutGrid} from '#/view/com/util/images/ImageLayoutGrid'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
||||
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {type EmbedType} from '#/types/bsky/post'
|
||||
import {type CommonProps} from './types'
|
||||
|
||||
@@ -6,10 +6,10 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
|
||||
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
|
||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
@@ -13,10 +13,10 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isFirefox} from '#/lib/browser'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useIsWithinMessage} from '#/components/dms/MessageContext'
|
||||
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
||||
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {
|
||||
HLSUnsupportedError,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type React from 'react'
|
||||
import {useCleanError} from '#/lib/hooks/useCleanError'
|
||||
import {logger} from '#/logger'
|
||||
import {type Shadow} from '#/state/cache/post-shadow'
|
||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||
import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation'
|
||||
import {useRequireAuth} from '#/state/session'
|
||||
import {useTheme} from '#/alf'
|
||||
@@ -32,6 +33,7 @@ export const BookmarkButton = memo(function BookmarkButton({
|
||||
const {mutateAsync: bookmark} = useBookmarkMutation()
|
||||
const cleanError = useCleanError()
|
||||
const requireAuth = useRequireAuth()
|
||||
const {feedDescriptor} = useFeedFeedbackContext()
|
||||
|
||||
const {viewer} = post
|
||||
const isBookmarked = !!viewer?.bookmarked
|
||||
@@ -50,7 +52,12 @@ export const BookmarkButton = memo(function BookmarkButton({
|
||||
post,
|
||||
})
|
||||
|
||||
logger.metric('post:bookmark', {logContext})
|
||||
logger.metric('post:bookmark', {
|
||||
uri: post.uri,
|
||||
authorDid: post.author.did,
|
||||
logContext,
|
||||
feedDescriptor,
|
||||
})
|
||||
|
||||
toast.show(
|
||||
<toast.Outer>
|
||||
@@ -85,7 +92,12 @@ export const BookmarkButton = memo(function BookmarkButton({
|
||||
uri: post.uri,
|
||||
})
|
||||
|
||||
logger.metric('post:unbookmark', {logContext})
|
||||
logger.metric('post:unbookmark', {
|
||||
uri: post.uri,
|
||||
authorDid: post.author.did,
|
||||
logContext,
|
||||
feedDescriptor,
|
||||
})
|
||||
|
||||
toast.show(
|
||||
<toast.Outer>
|
||||
|
||||
@@ -98,6 +98,7 @@ let PostMenuItems = ({
|
||||
richText,
|
||||
threadgateRecord,
|
||||
onShowLess,
|
||||
logContext,
|
||||
}: {
|
||||
testID: string
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -111,6 +112,7 @@ let PostMenuItems = ({
|
||||
timestamp: string
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||
}): React.ReactNode => {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const {_} = useLingui()
|
||||
@@ -210,9 +212,21 @@ let PostMenuItems = ({
|
||||
try {
|
||||
if (isThreadMuted) {
|
||||
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`))
|
||||
} else {
|
||||
muteThread()
|
||||
logger.metric('post:mute', {
|
||||
uri: postUri,
|
||||
authorDid: postAuthor.did,
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
Toast.show(
|
||||
_(msg`You will no longer receive notifications for this thread`),
|
||||
)
|
||||
@@ -272,6 +286,12 @@ let PostMenuItems = ({
|
||||
feedContext: postFeedContext,
|
||||
reqId: postReqId,
|
||||
})
|
||||
logger.metric('post:showMore', {
|
||||
uri: postUri,
|
||||
authorDid: postAuthor.did,
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
Toast.show(
|
||||
_(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
|
||||
)
|
||||
@@ -284,6 +304,12 @@ let PostMenuItems = ({
|
||||
feedContext: postFeedContext,
|
||||
reqId: postReqId,
|
||||
})
|
||||
logger.metric('post:showLess', {
|
||||
uri: postUri,
|
||||
authorDid: postAuthor.did,
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
if (onShowLess) {
|
||||
onShowLess({
|
||||
item: postUri,
|
||||
|
||||
@@ -29,6 +29,7 @@ let PostMenuButton = ({
|
||||
threadgateRecord,
|
||||
onShowLess,
|
||||
hitSlop,
|
||||
logContext,
|
||||
}: {
|
||||
testID: string
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -41,6 +42,7 @@ let PostMenuButton = ({
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
hitSlop?: Insets
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
|
||||
@@ -87,6 +89,7 @@ let PostMenuButton = ({
|
||||
timestamp={timestamp}
|
||||
threadgateRecord={threadgateRecord}
|
||||
onShowLess={onShowLess}
|
||||
logContext={logContext}
|
||||
/>
|
||||
)}
|
||||
</Menu.Root>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {useGate} from '#/lib/statsig/statsig'
|
||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {type Shadow} from '#/state/cache/post-shadow'
|
||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||
import {native} from '#/alf'
|
||||
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
|
||||
@@ -35,6 +36,7 @@ let ShareMenuButton = ({
|
||||
threadgateRecord,
|
||||
onShare,
|
||||
hitSlop,
|
||||
logContext,
|
||||
}: {
|
||||
testID: string
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -45,9 +47,11 @@ let ShareMenuButton = ({
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
onShare: () => void
|
||||
hitSlop?: Insets
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const gate = useGate()
|
||||
const {feedDescriptor} = useFeedFeedbackContext()
|
||||
|
||||
const ShareIcon = gate('alt_share_icon')
|
||||
? ArrowShareRightIcon
|
||||
@@ -65,13 +69,27 @@ let ShareMenuButton = ({
|
||||
setTimeout(menuControl.open)
|
||||
|
||||
logger.metric(
|
||||
'share:open',
|
||||
{context: big ? 'thread' : 'feed'},
|
||||
'post:share',
|
||||
{
|
||||
uri: post.uri,
|
||||
authorDid: post.author.did,
|
||||
logContext,
|
||||
feedDescriptor,
|
||||
postContext: big ? 'thread' : 'feed',
|
||||
},
|
||||
{statsig: true},
|
||||
)
|
||||
},
|
||||
}),
|
||||
[menuControl, setHasBeenOpen, big],
|
||||
[
|
||||
menuControl,
|
||||
setHasBeenOpen,
|
||||
big,
|
||||
logContext,
|
||||
feedDescriptor,
|
||||
post.uri,
|
||||
post.author.did,
|
||||
],
|
||||
)
|
||||
|
||||
const onNativeLongPress = () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {CountWheel} from '#/lib/custom-animations/CountWheel'
|
||||
import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {logger} from '#/logger'
|
||||
import {type Shadow} from '#/state/cache/types'
|
||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||
import {
|
||||
@@ -174,6 +175,12 @@ let PostControls = ({
|
||||
feedContext,
|
||||
reqId,
|
||||
})
|
||||
logger.metric('post:clickQuotePost', {
|
||||
uri: post.uri,
|
||||
authorDid: post.author.did,
|
||||
logContext,
|
||||
feedDescriptor,
|
||||
})
|
||||
openComposer({
|
||||
quote: post,
|
||||
onPost: onPostReply,
|
||||
@@ -217,7 +224,16 @@ let PostControls = ({
|
||||
testID="replyBtn"
|
||||
onPress={
|
||||
!replyDisabled
|
||||
? () => requireAuth(() => onPressReply())
|
||||
? () =>
|
||||
requireAuth(() => {
|
||||
logger.metric('post:clickReply', {
|
||||
uri: post.uri,
|
||||
authorDid: post.author.did,
|
||||
logContext,
|
||||
feedDescriptor,
|
||||
})
|
||||
onPressReply()
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
label={_(
|
||||
@@ -315,6 +331,7 @@ let PostControls = ({
|
||||
left: secondaryControlSpacingStyles.gap / 2,
|
||||
right: secondaryControlSpacingStyles.gap / 2,
|
||||
}}
|
||||
logContext={logContext}
|
||||
/>
|
||||
<PostMenuButton
|
||||
testID="postDropdownBtn"
|
||||
@@ -330,6 +347,7 @@ let PostControls = ({
|
||||
hitSlop={{
|
||||
left: secondaryControlSpacingStyles.gap / 2,
|
||||
}}
|
||||
logContext={logContext}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -389,6 +389,7 @@ let Card = ({
|
||||
{data && moderationOpts ? (
|
||||
status.isActive ? (
|
||||
<LiveStatus
|
||||
status={status}
|
||||
profile={data}
|
||||
embed={status.embed}
|
||||
padding="lg"
|
||||
|
||||
@@ -31,8 +31,8 @@ import {
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
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 {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {boostInterests, InterestTabs} from '#/components/InterestTabs'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
@@ -60,10 +60,16 @@ type Item =
|
||||
key: string
|
||||
}
|
||||
|
||||
export function FollowDialog({guide}: {guide: Follow10ProgressGuide}) {
|
||||
export function FollowDialog({
|
||||
guide,
|
||||
showArrow,
|
||||
}: {
|
||||
guide: Follow10ProgressGuide
|
||||
showArrow?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const control = Dialog.useDialogControl()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const {height: minHeight} = useWindowDimensions()
|
||||
|
||||
return (
|
||||
@@ -74,13 +80,12 @@ export function FollowDialog({guide}: {guide: Follow10ProgressGuide}) {
|
||||
control.open()
|
||||
logEvent('progressGuide:followDialog:open', {})
|
||||
}}
|
||||
size={gtMobile ? 'small' : 'large'}
|
||||
color="primary"
|
||||
variant="solid">
|
||||
<ButtonIcon icon={PersonGroupIcon} />
|
||||
size={gtPhone ? 'small' : 'large'}
|
||||
color="primary">
|
||||
<ButtonText>
|
||||
<Trans>Find people to follow</Trans>
|
||||
</ButtonText>
|
||||
{showArrow && <ButtonIcon icon={ArrowRightIcon} />}
|
||||
</Button>
|
||||
<Dialog.Outer control={control} nativeOptions={{minHeight}}>
|
||||
<Dialog.Handle />
|
||||
@@ -574,6 +579,7 @@ function FollowProfileCardInner({
|
||||
<ProfileCard.Outer>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
disabledPreview={!isWeb}
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
|
||||
@@ -2,37 +2,62 @@ import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
import {
|
||||
useProgressGuide,
|
||||
useProgressGuideControls,
|
||||
} 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 {Person_Stroke2_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {FollowDialog} from './FollowDialog'
|
||||
import {ProgressGuideTask} from './Task'
|
||||
|
||||
const TOTAL_AVATARS = 10
|
||||
|
||||
export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const {rightNavVisible} = useLayoutBreakpoints()
|
||||
const {currentAccount} = useSession()
|
||||
const followProgressGuide = useProgressGuide('follow-10')
|
||||
const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7')
|
||||
const guide = followProgressGuide || followAndLikeProgressGuide
|
||||
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) {
|
||||
return (
|
||||
<View style={[a.flex_col, a.gap_md, style]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_col,
|
||||
a.gap_md,
|
||||
a.rounded_md,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.p_lg,
|
||||
style,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.font_semi_bold,
|
||||
a.text_sm,
|
||||
{textTransform: 'uppercase'},
|
||||
]}>
|
||||
<Trans>Getting started</Trans>
|
||||
<Text style={[t.atoms.text, a.font_semi_bold, a.text_md]}>
|
||||
<Trans>Follow 10 people to get started</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -40,20 +65,28 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
|
||||
color="secondary"
|
||||
shape="round"
|
||||
label={_(msg`Dismiss getting started guide`)}
|
||||
onPress={endProgressGuide}>
|
||||
<ButtonIcon icon={Times} size="sm" />
|
||||
onPress={endProgressGuide}
|
||||
style={[a.bg_transparent, {marginTop: -6, marginRight: -6}]}>
|
||||
<ButtonIcon icon={Times} size="xs" />
|
||||
</Button>
|
||||
</View>
|
||||
{guide.guide === 'follow-10' && (
|
||||
<>
|
||||
<ProgressGuideTask
|
||||
current={guide.numFollows + 1}
|
||||
total={10 + 1}
|
||||
title={_(msg`Follow 10 accounts`)}
|
||||
subtitle={_(msg`Bluesky is better with friends!`)}
|
||||
/>
|
||||
<FollowDialog guide={guide} />
|
||||
</>
|
||||
<View
|
||||
style={[
|
||||
inlineLayout
|
||||
? [
|
||||
a.flex_row,
|
||||
a.flex_wrap,
|
||||
a.align_center,
|
||||
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' && (
|
||||
<>
|
||||
@@ -76,3 +109,73 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
|
||||
}
|
||||
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}
|
||||
thickness={3}
|
||||
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
|
||||
style={[
|
||||
a.text_sm,
|
||||
|
||||
@@ -11,6 +11,9 @@ import {RichTextTag} from '#/components/RichTextTag'
|
||||
import {Text, type TextProps} from '#/components/Typography'
|
||||
|
||||
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 &
|
||||
Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
|
||||
@@ -115,7 +118,8 @@ export function RichText({
|
||||
</ProfileHoverCard>,
|
||||
)
|
||||
} else if (link && AppBskyRichtextFacet.validateLink(link).success) {
|
||||
if (disableLinks) {
|
||||
const isValidLink = URL_REGEX.test(link.uri)
|
||||
if (!isValidLink || disableLinks) {
|
||||
els.push(toShortUrl(segment.text))
|
||||
} else {
|
||||
els.push(
|
||||
|
||||
@@ -48,10 +48,11 @@ export function RichTextTag({
|
||||
reset: resetRemove,
|
||||
} = useRemoveMutedWordsMutation()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const label = _(msg`Hashtag ${tag}`)
|
||||
const isCashtag = tag.startsWith('$')
|
||||
const label = isCashtag ? _(msg`Cashtag ${tag}`) : _(msg`Hashtag ${tag}`)
|
||||
const hint = isNative
|
||||
? _(msg`Long press to open tag menu for #${tag}`)
|
||||
: _(msg`Click to open tag menu for ${tag}`)
|
||||
? _(msg`Long press to open tag menu for ${isCashtag ? tag : `#${tag}`}`)
|
||||
: _(msg`Click to open tag menu for ${isCashtag ? tag : `#${tag}`}`)
|
||||
|
||||
const isMuted = Boolean(
|
||||
(preferences?.moderationPrefs.mutedWords?.find(
|
||||
@@ -109,20 +110,24 @@ export function RichTextTag({
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={_(msg`See ${tag} posts`)}
|
||||
label={_(msg`See ${isCashtag ? tag : `#${tag}`} posts`)}
|
||||
onPress={() => {
|
||||
navigation.push('Hashtag', {
|
||||
tag: encodeURIComponent(tag),
|
||||
})
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>See #{tag} posts</Trans>
|
||||
{isCashtag ? (
|
||||
<Trans>See {tag} posts</Trans>
|
||||
) : (
|
||||
<Trans>See #{tag} posts</Trans>
|
||||
)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Search} />
|
||||
</Menu.Item>
|
||||
{authorHandle && !isInvalidHandle(authorHandle) && (
|
||||
<Menu.Item
|
||||
label={_(msg`See ${tag} posts by user`)}
|
||||
label={_(msg`See ${isCashtag ? tag : `#${tag}`} posts by user`)}
|
||||
onPress={() => {
|
||||
navigation.push('Hashtag', {
|
||||
tag: encodeURIComponent(tag),
|
||||
@@ -130,7 +135,11 @@ export function RichTextTag({
|
||||
})
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>See #{tag} posts by user</Trans>
|
||||
{isCashtag ? (
|
||||
<Trans>See {tag} posts by user</Trans>
|
||||
) : (
|
||||
<Trans>See #{tag} posts by user</Trans>
|
||||
)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Person} />
|
||||
</Menu.Item>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {lazy} from 'react'
|
||||
import {lazy, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
// @ts-expect-error missing types
|
||||
import QRCode from 'react-native-qrcode-styled'
|
||||
@@ -102,39 +102,74 @@ export function QrCode({
|
||||
|
||||
export function QrCodeInner({link}: {link: string}) {
|
||||
const t = useTheme()
|
||||
const [logoArea, setLogoArea] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
} | null>(null)
|
||||
|
||||
const onLogoAreaChange = (area: {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}) => {
|
||||
setLogoArea(area)
|
||||
}
|
||||
|
||||
return (
|
||||
<QRCode
|
||||
data={link}
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
{height: 225, width: 225, backgroundColor: '#f3f3f3'},
|
||||
]}
|
||||
pieceSize={isWeb ? 8 : 6}
|
||||
padding={20}
|
||||
// pieceLiquidRadius={2}
|
||||
pieceBorderRadius={isWeb ? 4.5 : 3.5}
|
||||
outerEyesOptions={{
|
||||
topLeft: {
|
||||
borderRadius: [12, 12, 0, 12],
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
topRight: {
|
||||
borderRadius: [12, 12, 12, 0],
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
bottomLeft: {
|
||||
borderRadius: [12, 0, 12, 12],
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
}}
|
||||
innerEyesOptions={{borderRadius: 3}}
|
||||
logo={{
|
||||
href: require('../../../assets/logo.png'),
|
||||
scale: 0.95,
|
||||
padding: 2,
|
||||
hidePieces: true,
|
||||
}}
|
||||
/>
|
||||
<View style={{position: 'relative'}}>
|
||||
{/* An SVG version of the logo is placed on top of normal `QRCode` `logo` prop, since the PNG fails to load before the export completes on web. */}
|
||||
{isWeb && logoArea && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: logoArea.x,
|
||||
top: logoArea.y + 1,
|
||||
zIndex: 1,
|
||||
padding: 4,
|
||||
}}>
|
||||
<Logo width={logoArea.width - 14} height={logoArea.height - 14} />
|
||||
</View>
|
||||
)}
|
||||
<QRCode
|
||||
data={link}
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
{height: 225, width: 225, backgroundColor: '#f3f3f3'},
|
||||
]}
|
||||
pieceSize={isWeb ? 8 : 6}
|
||||
padding={20}
|
||||
pieceBorderRadius={isWeb ? 4.5 : 3.5}
|
||||
outerEyesOptions={{
|
||||
topLeft: {
|
||||
borderRadius: [12, 12, 0, 12],
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
topRight: {
|
||||
borderRadius: [12, 12, 12, 0],
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
bottomLeft: {
|
||||
borderRadius: [12, 0, 12, 12],
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
}}
|
||||
innerEyesOptions={{borderRadius: 3}}
|
||||
logo={{
|
||||
href: require('../../../assets/logo.png'),
|
||||
...(isWeb && {
|
||||
onChange: onLogoAreaChange,
|
||||
padding: 28,
|
||||
}),
|
||||
...(!isWeb && {
|
||||
padding: 2,
|
||||
scale: 0.95,
|
||||
}),
|
||||
hidePieces: true,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,12 @@ export function TrendingTopic({
|
||||
topic: raw,
|
||||
size,
|
||||
style,
|
||||
}: {topic: TrendingTopic; size?: 'large' | 'small'} & ViewStyleProp) {
|
||||
const t = useTheme()
|
||||
hovered,
|
||||
}: {
|
||||
topic: TrendingTopic
|
||||
size?: 'large' | 'small'
|
||||
hovered?: boolean
|
||||
} & ViewStyleProp) {
|
||||
const topic = useTopic(raw)
|
||||
|
||||
const isSmall = size === 'small'
|
||||
@@ -33,18 +37,14 @@ export function TrendingTopic({
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_full,
|
||||
a.border,
|
||||
t.atoms.border_contrast_medium,
|
||||
t.atoms.bg,
|
||||
isSmall
|
||||
? [
|
||||
{
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 2,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
]
|
||||
: [a.py_sm, a.px_md],
|
||||
: [a.py_xs, a.px_sm],
|
||||
hasIcon && {gap: 6},
|
||||
style,
|
||||
]}>
|
||||
@@ -93,6 +93,7 @@ export function TrendingTopic({
|
||||
a.font_semi_bold,
|
||||
a.leading_tight,
|
||||
isSmall ? [a.text_sm] : [a.text_md, {paddingBottom: 1}],
|
||||
hovered && {textDecorationLine: 'underline'},
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{topic.displayName}
|
||||
|
||||
@@ -68,10 +68,12 @@ export function SubscribeProfileButton({
|
||||
|
||||
const Icon = isSubscribed ? BellRingingIcon : BellPlusIcon
|
||||
|
||||
const tooltipVisible = showTooltip && !disableHint
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip.Outer
|
||||
visible={showTooltip && !disableHint}
|
||||
visible={tooltipVisible}
|
||||
onVisibleChange={onDismissTooltip}
|
||||
position="bottom">
|
||||
<Tooltip.Target>
|
||||
@@ -79,7 +81,7 @@ export function SubscribeProfileButton({
|
||||
accessibilityRole="button"
|
||||
testID="dmBtn"
|
||||
size="small"
|
||||
color="secondary"
|
||||
color={tooltipVisible ? 'primary_subtle' : 'secondary'}
|
||||
shape="round"
|
||||
label={_(msg`Get notified when ${name} posts`)}
|
||||
onPress={wrappedOnPress}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
|
||||
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
||||
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
|
||||
import {
|
||||
AgeAssuranceInitDialog,
|
||||
useDialogControl,
|
||||
@@ -27,6 +28,13 @@ import {useDeviceGeolocationApi} from '#/geolocation'
|
||||
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
|
||||
const aa = useAgeAssurance()
|
||||
if (aa.state.access === aa.Access.Full) return null
|
||||
if (aa.state.error === 'config') {
|
||||
return (
|
||||
<View style={style}>
|
||||
<AgeAssuranceConfigUnavailableError />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return <Inner style={style} />
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf'
|
||||
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
|
||||
import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog'
|
||||
import type * as Dialog from '#/components/Dialog'
|
||||
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
|
||||
@@ -19,6 +20,9 @@ export function AgeAssuranceAdmonition({
|
||||
const aa = useAgeAssurance()
|
||||
|
||||
if (aa.state.access === aa.Access.Full) return null
|
||||
if (aa.state.error === 'config') {
|
||||
return <AgeAssuranceConfigUnavailableError style={style} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Inner style={style} control={control}>
|
||||
|
||||
@@ -23,6 +23,7 @@ export function useInternalState() {
|
||||
const visible = useMemo(() => {
|
||||
if (aa.state.access === aa.Access.Full) return false
|
||||
if (aa.state.lastInitiatedAt) return false
|
||||
if (aa.state.error === 'config') return false
|
||||
if (hidden) return false
|
||||
if (nux && nux.completed) return false
|
||||
return true
|
||||
|
||||
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
|
||||
import {atoms as a, type ViewStyleProp} from '#/alf'
|
||||
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
|
||||
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
|
||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
@@ -26,33 +27,37 @@ export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
<View>
|
||||
<AgeAssuranceAdmonition>{copy.notice}</AgeAssuranceAdmonition>
|
||||
{aa.state.error === 'config' ? (
|
||||
<AgeAssuranceConfigUnavailableError />
|
||||
) : (
|
||||
<View>
|
||||
<AgeAssuranceAdmonition>{copy.notice}</AgeAssuranceAdmonition>
|
||||
|
||||
<Button
|
||||
label={_(msg`Don't show again`)}
|
||||
size="tiny"
|
||||
variant="solid"
|
||||
color="secondary_inverted"
|
||||
shape="round"
|
||||
onPress={() => {
|
||||
save({
|
||||
id: Nux.AgeAssuranceDismissibleNotice,
|
||||
completed: true,
|
||||
data: undefined,
|
||||
})
|
||||
logger.metric('ageAssurance:dismissSettingsNotice', {})
|
||||
}}
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
top: 12,
|
||||
right: 12,
|
||||
},
|
||||
]}>
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Don't show again`)}
|
||||
size="tiny"
|
||||
variant="solid"
|
||||
color="secondary_inverted"
|
||||
shape="round"
|
||||
onPress={() => {
|
||||
save({
|
||||
id: Nux.AgeAssuranceDismissibleNotice,
|
||||
completed: true,
|
||||
data: undefined,
|
||||
})
|
||||
logger.metric('ageAssurance:dismissSettingsNotice', {})
|
||||
}}
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
top: 12,
|
||||
right: 12,
|
||||
},
|
||||
]}>
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {type ViewStyleProp} from '#/alf'
|
||||
import * as Admonition from '#/components/Admonition'
|
||||
import {ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
|
||||
import {refetchConfig} from '#/ageAssurance/data'
|
||||
|
||||
export function AgeAssuranceConfigUnavailableError(props: ViewStyleProp) {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<Admonition.Outer type="error" style={props.style}>
|
||||
<Admonition.Row>
|
||||
<Admonition.Icon />
|
||||
<Admonition.Content>
|
||||
<Admonition.Text>
|
||||
<Trans>
|
||||
We were unable to load the age assurance configuration for your
|
||||
region, probably due to a network error. Some content and features
|
||||
may be unavailable temporarily. Please try again later.
|
||||
</Trans>
|
||||
</Admonition.Text>
|
||||
</Admonition.Content>
|
||||
<Admonition.Button
|
||||
color="negative_subtle"
|
||||
label={_(msg`Retry`)}
|
||||
onPress={() => refetchConfig().catch(() => {})}>
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={RetryIcon} />
|
||||
</Admonition.Button>
|
||||
</Admonition.Row>
|
||||
</Admonition.Outer>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
||||
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
|
||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||
import {ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||
@@ -44,6 +45,12 @@ export function AgeRestrictedScreen({
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content>
|
||||
<View style={[a.p_lg]}>
|
||||
{aa.state.error === 'config' && (
|
||||
<View style={[a.pb_lg]}>
|
||||
<AgeAssuranceConfigUnavailableError />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[a.align_start, a.pb_lg]}>
|
||||
<AgeAssuranceBadge />
|
||||
</View>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {createContext, useContext, useMemo, useState} from 'react'
|
||||
import {type AgeAssuranceRedirectDialogState} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {type Screen} from '#/components/dialogs/EmailDialog/types'
|
||||
import {type ReportSubject} from '#/components/moderation/ReportDialog'
|
||||
|
||||
type Control = Dialog.DialogControlProps
|
||||
|
||||
@@ -24,6 +25,7 @@ type ControlsContext = {
|
||||
share?: boolean
|
||||
}>
|
||||
ageAssuranceRedirectDialogControl: StatefulControl<AgeAssuranceRedirectDialogState>
|
||||
reportDialogControl: StatefulControl<{subject: ReportSubject}>
|
||||
}
|
||||
|
||||
const ControlsContext = createContext<ControlsContext | null>(null)
|
||||
@@ -51,6 +53,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
}>()
|
||||
const ageAssuranceRedirectDialogControl =
|
||||
useStatefulDialogControl<AgeAssuranceRedirectDialogState>()
|
||||
const reportDialogControl = useStatefulDialogControl<{
|
||||
subject: ReportSubject
|
||||
}>()
|
||||
|
||||
const ctx = useMemo<ControlsContext>(
|
||||
() => ({
|
||||
@@ -60,6 +65,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
emailDialogControl,
|
||||
linkWarningDialogControl,
|
||||
ageAssuranceRedirectDialogControl,
|
||||
reportDialogControl,
|
||||
}),
|
||||
[
|
||||
mutedWordsDialogControl,
|
||||
@@ -68,6 +74,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
emailDialogControl,
|
||||
linkWarningDialogControl,
|
||||
ageAssuranceRedirectDialogControl,
|
||||
reportDialogControl,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers'
|
||||
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
import {logger} from '#/logger'
|
||||
@@ -259,11 +259,11 @@ function DialogInner({
|
||||
_,
|
||||
])
|
||||
|
||||
const displayNameTooLong = useWarnMaxGraphemeCount({
|
||||
const displayNameTooLong = isOverMaxGraphemeCount({
|
||||
text: displayName,
|
||||
maxCount: DISPLAY_NAME_MAX_GRAPHEMES,
|
||||
})
|
||||
const descriptionTooLong = useWarnMaxGraphemeCount({
|
||||
const descriptionTooLong = isOverMaxGraphemeCount({
|
||||
text: descriptionRt,
|
||||
maxCount: DESCRIPTION_MAX_GRAPHEMES,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {atoms as a, select, useTheme, utils, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
|
||||
import {
|
||||
createIsEnabledCheck,
|
||||
isExistingUserAsOf,
|
||||
} from '#/components/dialogs/nuxs/utils'
|
||||
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_E2E} from '#/env'
|
||||
|
||||
export const enabled = createIsEnabledCheck(props => {
|
||||
return (
|
||||
!IS_E2E &&
|
||||
isExistingUserAsOf(
|
||||
'2026-01-16T00:00:00.000Z',
|
||||
props.currentProfile.createdAt,
|
||||
) &&
|
||||
props.gate('live_now_beta')
|
||||
)
|
||||
})
|
||||
|
||||
export function LiveNowBetaDialog() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const nuxDialogs = useNuxDialogContext()
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
Dialog.useAutoOpen(control)
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
nuxDialogs.dismissActiveNux()
|
||||
}, [nuxDialogs])
|
||||
|
||||
const shadowColor = useMemo(() => {
|
||||
return select(t.name, {
|
||||
light: utils.alpha(t.palette.primary_900, 0.4),
|
||||
dark: utils.alpha(t.palette.primary_25, 0.4),
|
||||
dim: utils.alpha(t.palette.primary_25, 0.4),
|
||||
})
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={onClose}
|
||||
nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle fill={t.palette.primary_700} />
|
||||
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Show when you’re live`)}
|
||||
style={[web({maxWidth: 440})]}
|
||||
contentContainerStyle={[
|
||||
{
|
||||
paddingTop: 0,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.overflow_hidden,
|
||||
{
|
||||
gap: 16,
|
||||
paddingTop: isWeb ? 24 : 40,
|
||||
borderTopLeftRadius: a.rounded_md.borderRadius,
|
||||
borderTopRightRadius: a.rounded_md.borderRadius,
|
||||
},
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
t.palette.primary_100,
|
||||
utils.alpha(t.palette.primary_100, 0),
|
||||
]}
|
||||
locations={[0, 1]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 0, y: 1}}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<BeakerIcon fill={t.palette.primary_700} size="sm" />
|
||||
<Text
|
||||
style={[
|
||||
a.font_semi_bold,
|
||||
{
|
||||
color: t.palette.primary_700,
|
||||
},
|
||||
]}>
|
||||
<Trans>Beta Feature</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.w_full,
|
||||
{
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 32,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
borderRadius: 24,
|
||||
aspectRatio: 652 / 211,
|
||||
},
|
||||
isWeb
|
||||
? [
|
||||
{
|
||||
boxShadow: `0px 10px 15px -3px ${shadowColor}`,
|
||||
},
|
||||
]
|
||||
: [
|
||||
t.atoms.shadow_md,
|
||||
{
|
||||
shadowColor,
|
||||
shadowOpacity: 0.2,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
]}>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={require('../../../../assets/images/live_now_beta.webp')}
|
||||
style={[
|
||||
a.w_full,
|
||||
{
|
||||
aspectRatio: 652 / 211,
|
||||
},
|
||||
]}
|
||||
alt={_(
|
||||
msg({
|
||||
message: `A screenshot of a post from @esb.lol, showing the user is currently livestreaming content on Twitch. The post reads: "Hello! I'm live on Twitch, and I'm testing Bluesky's latest feature too!"`,
|
||||
comment:
|
||||
'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.',
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[a.align_center, a.px_xl, a.gap_2xl, a.pb_sm]}>
|
||||
<View style={[a.gap_sm, a.align_center]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_3xl,
|
||||
a.leading_tight,
|
||||
a.font_bold,
|
||||
a.text_center,
|
||||
{
|
||||
fontSize: isWeb ? 28 : 32,
|
||||
maxWidth: 360,
|
||||
},
|
||||
]}>
|
||||
<Trans>Show when you’re live</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
a.text_center,
|
||||
{
|
||||
maxWidth: 340,
|
||||
},
|
||||
]}>
|
||||
<Trans>
|
||||
Streaming on Twitch? Set your live status on Bluesky to add a
|
||||
badge to your avatar. Tapping it takes people straight to your
|
||||
stream.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{!isWeb && (
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="large"
|
||||
color="primary"
|
||||
onPress={() => {
|
||||
control.close()
|
||||
}}
|
||||
style={[a.w_full]}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -20,9 +20,9 @@ import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {type SessionAccount, useSession} from '#/state/session'
|
||||
import {useOnboardingState} from '#/state/shell'
|
||||
import {
|
||||
enabled as isFindContactsAnnouncementEnabled,
|
||||
FindContactsAnnouncement,
|
||||
} from '#/components/dialogs/nuxs/FindContactsAnnouncement'
|
||||
enabled as isLiveNowBetaDialogEnabled,
|
||||
LiveNowBetaDialog,
|
||||
} from '#/components/dialogs/nuxs/LiveNowBetaDialog'
|
||||
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
|
||||
import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils'
|
||||
import {useGeolocation} from '#/geolocation'
|
||||
@@ -37,8 +37,8 @@ const queuedNuxs: {
|
||||
enabled?: (props: EnabledCheckProps) => boolean
|
||||
}[] = [
|
||||
{
|
||||
id: Nux.FindContactsAnnouncement,
|
||||
enabled: isFindContactsAnnouncementEnabled,
|
||||
id: Nux.LiveNowBetaDialog,
|
||||
enabled: isLiveNowBetaDialogEnabled,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -186,9 +186,7 @@ function Inner({
|
||||
return (
|
||||
<Context.Provider value={ctx}>
|
||||
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
|
||||
{activeNux === Nux.FindContactsAnnouncement && (
|
||||
<FindContactsAnnouncement />
|
||||
)}
|
||||
{activeNux === Nux.LiveNowBetaDialog && <LiveNowBetaDialog />}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,4 +1,4 @@
|
||||
import React, {useRef} from 'react'
|
||||
import {useMemo, useRef} from 'react'
|
||||
import {type DimensionValue, Pressable, View} from 'react-native'
|
||||
import Animated, {
|
||||
type AnimatedRef,
|
||||
@@ -34,7 +34,7 @@ export function ConstrainedImage({
|
||||
* Computed as a % value to apply as `paddingTop`, this basically controls
|
||||
* the height of the image.
|
||||
*/
|
||||
const outerAspectRatio = React.useMemo<DimensionValue>(() => {
|
||||
const outerAspectRatio = useMemo<DimensionValue>(() => {
|
||||
const ratio = isNative
|
||||
? Math.min(1 / aspectRatio, minMobileAspectRatio ?? 16 / 9) // 9:16 bounding box
|
||||
: Math.min(1 / aspectRatio, 1) // 1:1 bounding box
|
||||
@@ -127,6 +127,7 @@ export function AutoSizedImage({
|
||||
}
|
||||
}
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
<MediaInsetBorder />
|
||||
|
||||
@@ -29,7 +29,7 @@ interface Props {
|
||||
viewContext?: PostEmbedViewContext
|
||||
insetBorderStyle?: StyleProp<ViewStyle>
|
||||
containerRefs: AnimatedRef<any>[]
|
||||
thumbDimsRef: React.MutableRefObject<(Dimensions | null)[]>
|
||||
thumbDimsRef: React.RefObject<(Dimensions | null)[]>
|
||||
}
|
||||
|
||||
export function GalleryItem({
|
||||
@@ -87,6 +87,7 @@ export function GalleryItem({
|
||||
height: e.source.height,
|
||||
}
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
<MediaInsetBorder style={insetBorderStyle} />
|
||||
</Pressable>
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
import React from 'react'
|
||||
import {useRef} from 'react'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
|
||||
import {type AppBskyEmbedImages} from '@atproto/api'
|
||||
|
||||
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {type Dimensions} from '../../lightbox/ImageViewing/@types'
|
||||
import {GalleryItem} from './Gallery'
|
||||
|
||||
interface ImageLayoutGridProps {
|
||||
@@ -60,7 +60,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
|
||||
const containerRef2 = useAnimatedRef()
|
||||
const containerRef3 = useAnimatedRef()
|
||||
const containerRef4 = useAnimatedRef()
|
||||
const thumbDimsRef = React.useRef<(Dimensions | null)[]>([])
|
||||
const thumbDimsRef = useRef<(Dimensions | null)[]>([])
|
||||
|
||||
switch (count) {
|
||||
case 2: {
|
||||
@@ -41,7 +41,7 @@ export function Inner() {
|
||||
}, [setTrendingDisabled])
|
||||
|
||||
return error || noTopics ? null : (
|
||||
<View style={[t.atoms.border_contrast_low, a.border_t]}>
|
||||
<View style={[t.atoms.border_contrast_low, a.border_t, a.border_b]}>
|
||||
<BlockDrawerGesture>
|
||||
<ScrollView
|
||||
horizontal
|
||||
@@ -99,10 +99,9 @@ export function Inner() {
|
||||
<View style={[a.py_lg]}>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_sm,
|
||||
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}
|
||||
</Text>
|
||||
|
||||
@@ -18,7 +18,6 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {LinkPreview} from './LinkPreview'
|
||||
@@ -172,26 +171,13 @@ function DialogInner({
|
||||
</TextField.Root>
|
||||
</View>
|
||||
{(liveLinkError || linkMetaError) && (
|
||||
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
|
||||
<WarningIcon
|
||||
style={[{color: t.palette.negative_500}]}
|
||||
size="sm"
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
a.flex_1,
|
||||
a.font_semi_bold,
|
||||
{color: t.palette.negative_500},
|
||||
]}>
|
||||
{liveLinkError ? (
|
||||
<Trans>This is not a valid link</Trans>
|
||||
) : (
|
||||
cleanError(linkMetaError)
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
<Admonition type="error">
|
||||
{liveLinkError ? (
|
||||
<Trans>This is not a valid link</Trans>
|
||||
) : (
|
||||
cleanError(linkMetaError)
|
||||
)}
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
<LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} />
|
||||
|
||||
@@ -6,13 +6,14 @@ import {useLingui} from '@lingui/react'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {definitelyUrl} from '#/lib/strings/url-helpers'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useLiveNowConfig} from '#/state/service-config'
|
||||
import {useTickEveryMinute} from '#/state/shell'
|
||||
import {atoms as a, ios, native, platform, useTheme, web} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import {getLiveServiceNames} from '#/components/live/utils'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import * as Select from '#/components/Select'
|
||||
@@ -49,6 +50,10 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
|
||||
const [duration, setDuration] = useState(60)
|
||||
const moderationOpts = useModerationOpts()
|
||||
const tick = useTickEveryMinute()
|
||||
const liveNowConfig = useLiveNowConfig()
|
||||
const {formatted: allowedServices} = getLiveServiceNames(
|
||||
liveNowConfig.allowedDomains,
|
||||
)
|
||||
|
||||
const time = useCallback(
|
||||
(offset: number) => {
|
||||
@@ -139,27 +144,21 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
{(liveLinkError || linkMetaError) && (
|
||||
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
|
||||
<WarningIcon
|
||||
style={[{color: t.palette.negative_500}]}
|
||||
size="sm"
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
a.flex_1,
|
||||
a.font_semi_bold,
|
||||
{color: t.palette.negative_500},
|
||||
]}>
|
||||
{liveLinkError ? (
|
||||
<Trans>This is not a valid link</Trans>
|
||||
) : (
|
||||
cleanError(linkMetaError)
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
{liveLinkError || linkMetaError ? (
|
||||
<Admonition type="error">
|
||||
{liveLinkError ? (
|
||||
<Trans>This is not a valid link</Trans>
|
||||
) : (
|
||||
cleanError(linkMetaError)
|
||||
)}
|
||||
</Admonition>
|
||||
) : (
|
||||
<Admonition type="tip">
|
||||
<Trans>
|
||||
The following services are enabled for your account:{' '}
|
||||
{allowedServices}
|
||||
</Trans>
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
<LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} />
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import {useCallback, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyActorDefs, ToolsOzoneReportDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {atoms as a, web} from '#/alf'
|
||||
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 {Text} from '#/components/Typography'
|
||||
|
||||
export function GoLiveDisabledDialog({
|
||||
control,
|
||||
status,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
status: AppBskyActorDefs.StatusView
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle />
|
||||
<DialogInner control={control} status={status} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogInner({
|
||||
control,
|
||||
status,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
status: AppBskyActorDefs.StatusView
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const agent = useAgent()
|
||||
const [details, setDetails] = useState('')
|
||||
|
||||
const {mutate, isPending} = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!agent.session?.did) {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
if (!status.uri || !status.cid) {
|
||||
throw new Error('Status is missing uri or cid')
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
logger.info('Submitting go live appeal', {
|
||||
details,
|
||||
})
|
||||
} else {
|
||||
await agent.createModerationReport(
|
||||
{
|
||||
reasonType: ToolsOzoneReportDefs.REASONAPPEAL,
|
||||
subject: {
|
||||
$type: 'com.atproto.repo.strongRef',
|
||||
uri: status.uri,
|
||||
cid: status.cid,
|
||||
},
|
||||
reason: details,
|
||||
},
|
||||
{
|
||||
encoding: 'application/json',
|
||||
headers: BLUESKY_MOD_SERVICE_HEADERS,
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(_(msg`Failed to submit appeal, please try again.`), {
|
||||
type: 'error',
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
control.close()
|
||||
Toast.show(_(msg({message: 'Appeal submitted', context: 'toast'})), {
|
||||
type: 'success',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = useCallback(() => mutate(), [mutate])
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Appeal livestream suspension`)}
|
||||
style={[web({maxWidth: 400})]}>
|
||||
<View style={[a.gap_lg]}>
|
||||
<View style={[a.gap_md]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_2xl,
|
||||
a.font_semi_bold,
|
||||
a.leading_snug,
|
||||
a.pr_4xl,
|
||||
]}>
|
||||
<Trans>Going live is currently disabled for your account</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
You are currently blocked from using the Go Live feature. To
|
||||
appeal this moderation decision, please submit the form below.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
This appeal will be sent to Bluesky's moderation service.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_md]}>
|
||||
<Dialog.Input
|
||||
label={_(msg`Text input field`)}
|
||||
placeholder={_(
|
||||
msg`Please explain why you think your Go Live access was incorrectly disabled.`,
|
||||
)}
|
||||
value={details}
|
||||
onChangeText={setDetails}
|
||||
autoFocus={true}
|
||||
numberOfLines={3}
|
||||
multiline
|
||||
maxLength={300}
|
||||
/>
|
||||
<Button
|
||||
testID="submitBtn"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
onPress={onSubmit}
|
||||
label={_(msg`Submit`)}>
|
||||
<ButtonText>{_(msg`Submit`)}</ButtonText>
|
||||
{isPending && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,9 @@ import {unstableCacheProfileView} from '#/state/queries/profile'
|
||||
import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
|
||||
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
|
||||
import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
@@ -28,6 +31,7 @@ export function LiveStatusDialog({
|
||||
control,
|
||||
profile,
|
||||
embed,
|
||||
status,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
profile: bsky.profile.AnyProfileView
|
||||
@@ -38,7 +42,12 @@ export function LiveStatusDialog({
|
||||
return (
|
||||
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle difference={!!embed.external.thumb} />
|
||||
<DialogInner profile={profile} embed={embed} navigation={navigation} />
|
||||
<DialogInner
|
||||
status={status}
|
||||
profile={profile}
|
||||
embed={embed}
|
||||
navigation={navigation}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -47,10 +56,12 @@ function DialogInner({
|
||||
profile,
|
||||
embed,
|
||||
navigation,
|
||||
status,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
embed: AppBskyEmbedExternal.View
|
||||
navigation: NavigationProp
|
||||
status: AppBskyActorDefs.StatusView
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const control = Dialog.useDialogContext()
|
||||
@@ -69,6 +80,7 @@ function DialogInner({
|
||||
contentContainerStyle={[a.pt_0, a.px_0]}
|
||||
style={[web({maxWidth: 420}), a.overflow_hidden]}>
|
||||
<LiveStatus
|
||||
status={status}
|
||||
profile={profile}
|
||||
embed={embed}
|
||||
onPressOpenProfile={onPressOpenProfile}
|
||||
@@ -79,11 +91,13 @@ function DialogInner({
|
||||
}
|
||||
|
||||
export function LiveStatus({
|
||||
status,
|
||||
profile,
|
||||
embed,
|
||||
padding = 'xl',
|
||||
onPressOpenProfile,
|
||||
}: {
|
||||
status: AppBskyActorDefs.StatusView
|
||||
profile: bsky.profile.AnyProfileView
|
||||
embed: AppBskyEmbedExternal.View
|
||||
padding?: 'lg' | 'xl'
|
||||
@@ -94,6 +108,8 @@ export function LiveStatus({
|
||||
const queryClient = useQueryClient()
|
||||
const openLink = useOpenLink()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const reportDialogControl = useGlobalReportDialogControl()
|
||||
const dialogContext = Dialog.useDialogContext()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -205,15 +221,43 @@ export function LiveStatus({
|
||||
</Button>
|
||||
</ProfileCard.Header>
|
||||
)}
|
||||
<Text
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.text_center,
|
||||
t.atoms.text_contrast_low,
|
||||
a.text_sm,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.flex_1,
|
||||
a.pt_sm,
|
||||
]}>
|
||||
<Trans>Live feature is in beta testing</Trans>
|
||||
</Text>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
|
||||
<CircleInfoIcon size="sm" fill={t.atoms.text_contrast_low.color} />
|
||||
<Text style={[t.atoms.text_contrast_low, a.text_sm]}>
|
||||
<Trans>Live feature is in beta</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
{status && (
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Report this livestream`)}
|
||||
{...createStaticClick(() => {
|
||||
function open() {
|
||||
reportDialogControl.open({
|
||||
subject: {
|
||||
...status,
|
||||
$type: 'app.bsky.actor.defs#statusView',
|
||||
},
|
||||
})
|
||||
}
|
||||
if (dialogContext.isWithinDialog) {
|
||||
dialogContext.close(open)
|
||||
} else {
|
||||
open()
|
||||
}
|
||||
})}
|
||||
style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Report</Trans>
|
||||
</SimpleInlineLinkText>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -18,10 +18,10 @@ import {useLiveNowConfig} from '#/state/service-config'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {useDialogContext} from '#/components/Dialog'
|
||||
import {getLiveServiceNames} from '#/components/live/utils'
|
||||
|
||||
export function useLiveLinkMetaQuery(url: string | null) {
|
||||
const liveNowConfig = useLiveNowConfig()
|
||||
const {currentAccount} = useSession()
|
||||
const {_} = useLingui()
|
||||
|
||||
const agent = useAgent()
|
||||
@@ -30,13 +30,14 @@ export function useLiveLinkMetaQuery(url: string | null) {
|
||||
queryKey: ['link-meta', url],
|
||||
queryFn: async () => {
|
||||
if (!url) return undefined
|
||||
const config = liveNowConfig.find(cfg => cfg.did === currentAccount?.did)
|
||||
|
||||
if (!config) throw new Error(_(msg`You are not allowed to go live`))
|
||||
|
||||
const urlp = new URL(url)
|
||||
if (!config.domains.includes(urlp.hostname)) {
|
||||
throw new Error(_(msg`${urlp.hostname} is not a valid URL`))
|
||||
if (!liveNowConfig.allowedDomains.has(urlp.hostname)) {
|
||||
const {formatted} = getLiveServiceNames(liveNowConfig.allowedDomains)
|
||||
throw new Error(
|
||||
_(
|
||||
msg`This service is not supported while the Live feature is in beta. Allowed services: ${formatted}.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return await getLinkMeta(agent, url)
|
||||
|
||||
@@ -35,3 +35,28 @@ export function useDebouncedValue<T>(val: T, delayMs: number): T {
|
||||
|
||||
return prev
|
||||
}
|
||||
|
||||
const serviceUrlToNameMap: Record<string, string> = {
|
||||
'twitch.tv': 'Twitch',
|
||||
'www.twitch.tv': 'Twitch',
|
||||
'youtube.com': 'YouTube',
|
||||
'www.youtube.com': 'YouTube',
|
||||
'youtu.be': 'YouTube',
|
||||
'nba.com': 'NBA',
|
||||
'www.nba.com': 'NBA',
|
||||
'nba.smart.link': 'nba.smart.link',
|
||||
'espn.com': 'ESPN',
|
||||
'www.espn.com': 'ESPN',
|
||||
'stream.place': 'Streamplace',
|
||||
'skylight.social': 'Skylight',
|
||||
}
|
||||
|
||||
export function getLiveServiceNames(domains: Set<string>) {
|
||||
const names = Array.from(
|
||||
new Set(Array.from(domains.values()).map(d => serviceUrlToNameMap[d] || d)),
|
||||
)
|
||||
return {
|
||||
names,
|
||||
formatted: names.join(', '),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export function useSubmitReportMutation() {
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'status':
|
||||
case 'post':
|
||||
case 'list':
|
||||
case 'feed':
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
ToolsOzoneReportDefs as OzoneReportDefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {type ParsedReportSubject} from '#/components/moderation/ReportDialog/types'
|
||||
|
||||
export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
|
||||
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.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'])
|
||||
|
||||
@@ -14,6 +14,12 @@ export function useCopyForSubject(subject: ParsedReportSubject) {
|
||||
subtitle: _(msg`Why should this user be reviewed?`),
|
||||
}
|
||||
}
|
||||
case 'status': {
|
||||
return {
|
||||
title: _(msg`Report this livestream`),
|
||||
subtitle: _(msg`Why should this livestream be reviewed?`),
|
||||
}
|
||||
}
|
||||
case 'post': {
|
||||
return {
|
||||
title: _(msg`Report this post`),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {type ScrollView} from 'react-native-gesture-handler'
|
||||
import {Pressable, type ScrollView, View} from 'react-native'
|
||||
import {type AppBskyLabelerDefs, BSKY_LABELER_DID} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -17,6 +16,7 @@ import {atoms as a, useGutters, useTheme} from '#/alf'
|
||||
import * as Admonition from '#/components/Admonition'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotate'
|
||||
import {
|
||||
@@ -32,6 +32,7 @@ import {Text} from '#/components/Typography'
|
||||
import {useSubmitReportMutation} from './action'
|
||||
import {
|
||||
BSKY_LABELER_ONLY_REPORT_REASONS,
|
||||
BSKY_LABELER_ONLY_SUBJECT_TYPES,
|
||||
NEW_TO_OLD_REASONS_MAP,
|
||||
SUPPORT_PAGE,
|
||||
} from './const'
|
||||
@@ -45,17 +46,27 @@ import {
|
||||
useReportOptions,
|
||||
} from './utils/useReportOptions'
|
||||
|
||||
export {type ReportSubject} from './types'
|
||||
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
|
||||
|
||||
export function useGlobalReportDialogControl() {
|
||||
return useGlobalDialogsControlContext().reportDialogControl
|
||||
}
|
||||
|
||||
const logger = Logger.create(Logger.Context.ReportDialog)
|
||||
|
||||
export function GlobalReportDialog() {
|
||||
const {value, control} = useGlobalReportDialogControl()
|
||||
return <ReportDialog control={control} subject={value?.subject} />
|
||||
}
|
||||
|
||||
export function ReportDialog(
|
||||
props: Omit<ReportDialogProps, 'subject'> & {
|
||||
subject: ReportSubject
|
||||
subject?: ReportSubject
|
||||
},
|
||||
) {
|
||||
const subject = React.useMemo(
|
||||
() => parseReportSubject(props.subject),
|
||||
() => (props.subject ? parseReportSubject(props.subject) : undefined),
|
||||
[props.subject],
|
||||
)
|
||||
const onClose = React.useCallback(() => {
|
||||
@@ -117,8 +128,10 @@ function Inner(props: ReportDialogProps) {
|
||||
const isBskyOnlyReason = state?.selectedOption?.reason
|
||||
? BSKY_LABELER_ONLY_REPORT_REASONS.has(state.selectedOption.reason)
|
||||
: false
|
||||
// some subjects (chats) only go to Bluesky
|
||||
const isBskyOnlySubject = props.subject.type === 'convoMessage'
|
||||
// some subjects ONLY go to Bluesky
|
||||
const isBskyOnlySubject = BSKY_LABELER_ONLY_SUBJECT_TYPES.has(
|
||||
props.subject.type,
|
||||
)
|
||||
|
||||
/**
|
||||
* Labelers that support this `subject` and its NSID collection
|
||||
@@ -825,12 +838,7 @@ function LabelerCard({
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>By {sanitizeHandle(labeler.creator.handle, '@')}</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -18,6 +18,7 @@ export type ReportSubject =
|
||||
| $Typed<AppBskyActorDefs.ProfileViewBasic>
|
||||
| $Typed<AppBskyActorDefs.ProfileView>
|
||||
| $Typed<AppBskyActorDefs.ProfileViewDetailed>
|
||||
| $Typed<AppBskyActorDefs.StatusView>
|
||||
| $Typed<AppBskyGraphDefs.ListView>
|
||||
| $Typed<AppBskyFeedDefs.GeneratorView>
|
||||
| $Typed<AppBskyGraphDefs.StarterPackView>
|
||||
@@ -38,6 +39,12 @@ export type ParsedReportSubject =
|
||||
quote: boolean
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'status'
|
||||
uri: string
|
||||
cid: string
|
||||
nsid: string
|
||||
}
|
||||
| {
|
||||
type: 'list'
|
||||
uri: string
|
||||
|
||||
@@ -33,6 +33,14 @@ export function parseReportSubject(
|
||||
did: subject.did,
|
||||
nsid: 'app.bsky.actor.profile',
|
||||
}
|
||||
} else if (AppBskyActorDefs.isStatusView(subject)) {
|
||||
if (!subject.uri || !subject.cid) return
|
||||
return {
|
||||
type: 'status',
|
||||
uri: subject.uri,
|
||||
cid: subject.cid,
|
||||
nsid: 'app.bsky.actor.status',
|
||||
}
|
||||
} else if (AppBskyGraphDefs.isListView(subject)) {
|
||||
return {
|
||||
type: 'list',
|
||||
|
||||
Vendored
+15
-5
@@ -108,8 +108,18 @@ export const GCP_PROJECT_ID: number =
|
||||
* URLs for the app config web worker. Can be a
|
||||
* locally running server, see `env.example` for more.
|
||||
*/
|
||||
export const BAPP_CONFIG_DEV_URL = process.env.BAPP_CONFIG_DEV_URL
|
||||
export const BAPP_CONFIG_PROD_URL = `https://ip.bsky.app`
|
||||
export const BAPP_CONFIG_URL = IS_DEV
|
||||
? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_PROD_URL)
|
||||
: BAPP_CONFIG_PROD_URL
|
||||
export const GEOLOCATION_DEV_URL = process.env.GEOLOCATION_DEV_URL
|
||||
export const GEOLOCATION_PROD_URL = `https://ip.bsky.app`
|
||||
export const GEOLOCATION_URL = IS_DEV
|
||||
? (GEOLOCATION_DEV_URL ?? GEOLOCATION_PROD_URL)
|
||||
: GEOLOCATION_PROD_URL
|
||||
|
||||
/**
|
||||
* URLs for the live-event config web worker. Can be a
|
||||
* locally running server, see `env.example` for more.
|
||||
*/
|
||||
export const LIVE_EVENTS_DEV_URL = process.env.LIVE_EVENTS_DEV_URL
|
||||
export const LIVE_EVENTS_PROD_URL = `https://live-events.workers.bsky.app`
|
||||
export const LIVE_EVENTS_URL = IS_DEV
|
||||
? (LIVE_EVENTS_DEV_URL ?? LIVE_EVENTS_PROD_URL)
|
||||
: LIVE_EVENTS_PROD_URL
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useTrendingSettings} from '#/state/preferences/trending'
|
||||
import {atoms as a, useLayoutBreakpoints} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
|
||||
import {TrendingInterstitial} from '#/components/interstitials/Trending'
|
||||
import {LiveEventFeedCardWide} from '#/features/liveEvents/components/LiveEventFeedCardWide'
|
||||
import {
|
||||
LiveEventFeedOptionsMenu,
|
||||
useDialogControl,
|
||||
} from '#/features/liveEvents/components/LiveEventFeedOptionsMenu'
|
||||
import {useUserPreferencedLiveEvents} from '#/features/liveEvents/context'
|
||||
import {type LiveEventFeed} from '#/features/liveEvents/types'
|
||||
|
||||
export function DiscoverFeedLiveEventFeedsAndTrendingBanner() {
|
||||
const events = useUserPreferencedLiveEvents()
|
||||
const {rightNavVisible} = useLayoutBreakpoints()
|
||||
const {trendingDisabled} = useTrendingSettings()
|
||||
|
||||
if (!events.feeds.length) {
|
||||
if (!rightNavVisible && !trendingDisabled) {
|
||||
// only show trending on mobile when live event banner is not shown
|
||||
return <TrendingInterstitial />
|
||||
} else {
|
||||
// no feed, no trending
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// On desktop, we show in the sidebar
|
||||
if (rightNavVisible) return null
|
||||
|
||||
return events.feeds.map(feed => <Inner feed={feed} key={feed.id} />)
|
||||
}
|
||||
|
||||
function Inner({feed}: {feed: LiveEventFeed}) {
|
||||
const {_} = useLingui()
|
||||
const optionsMenuControl = useDialogControl()
|
||||
const layout = feed.layouts.wide
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={[a.px_lg, a.pt_md, a.pb_xs]}>
|
||||
<View>
|
||||
<LiveEventFeedCardWide feed={feed} metricContext="discover" />
|
||||
|
||||
<Button
|
||||
label={_(msg`Configure live event banner`)}
|
||||
size="tiny"
|
||||
shape="round"
|
||||
style={[a.absolute, a.z_10, {top: 6, right: 6}]}
|
||||
onPress={() => {
|
||||
optionsMenuControl.open()
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.rounded_full,
|
||||
{
|
||||
backgroundColor: layout.overlayColor,
|
||||
opacity: hovered || pressed ? 0.8 : 0.6,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<EllipsisIcon
|
||||
size="sm"
|
||||
fill={layout.textColor}
|
||||
style={[a.z_20]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<LiveEventFeedOptionsMenu
|
||||
feed={feed}
|
||||
control={optionsMenuControl}
|
||||
metricContext="discover"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {LiveEventFeedCardWide} from '#/features/liveEvents/components/LiveEventFeedCardWide'
|
||||
import {useLiveEvents} from '#/features/liveEvents/context'
|
||||
|
||||
export function ExploreScreenLiveEventFeedsBanner() {
|
||||
const t = useTheme()
|
||||
const events = useLiveEvents()
|
||||
return events.feeds.map(feed => (
|
||||
<View
|
||||
key={feed.id}
|
||||
style={[a.p_lg, a.border_b, t.atoms.border_contrast_low]}>
|
||||
<LiveEventFeedCardWide feed={feed} metricContext="explore" />
|
||||
</View>
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import {useEffect, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {atoms as a, utils} from '#/alf'
|
||||
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
|
||||
import {Link} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {
|
||||
type LiveEventFeed,
|
||||
type LiveEventFeedMetricContext,
|
||||
} from '#/features/liveEvents/types'
|
||||
|
||||
const roundedStyles = [a.rounded_md, a.curve_continuous]
|
||||
|
||||
export function LiveEventFeedCardCompact({
|
||||
feed,
|
||||
metricContext,
|
||||
}: {
|
||||
feed: LiveEventFeed
|
||||
metricContext: LiveEventFeedMetricContext
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
const layout = feed.layouts.compact
|
||||
const overlayColor = layout.overlayColor
|
||||
const textColor = layout.textColor
|
||||
const url = useMemo(() => {
|
||||
// Validated in multiple places on the backend
|
||||
if (isBskyCustomFeedUrl(feed.url)) {
|
||||
return new URL(feed.url).pathname
|
||||
}
|
||||
return '/'
|
||||
}, [feed.url])
|
||||
|
||||
useEffect(() => {
|
||||
logger.metric('liveEvents:feedBanner:seen', {
|
||||
feed: feed.url,
|
||||
context: metricContext,
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={url}
|
||||
label={_(msg`Live event happening now: ${feed.title}`)}
|
||||
style={[a.w_full]}
|
||||
onPress={() => {
|
||||
logger.metric('liveEvents:feedBanner:click', {
|
||||
feed: feed.url,
|
||||
context: metricContext,
|
||||
})
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<View style={[roundedStyles, a.shadow_md, a.w_full]}>
|
||||
<View
|
||||
style={[a.w_full, a.align_start, a.overflow_hidden, roundedStyles]}>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={{uri: layout.image}}
|
||||
placeholder={{blurhash: layout.blurhash}}
|
||||
style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
|
||||
contentFit="cover"
|
||||
placeholderContentFit="cover"
|
||||
/>
|
||||
|
||||
<LinearGradient
|
||||
colors={[overlayColor, utils.alpha(overlayColor, 0)]}
|
||||
locations={[0, 1]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 0}}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.transition_opacity,
|
||||
{
|
||||
transitionDuration: '200ms',
|
||||
opacity: hovered || pressed ? 0.6 : 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<View style={[a.w_full, a.justify_end]}>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
overlayColor,
|
||||
utils.alpha(overlayColor, 0.7),
|
||||
utils.alpha(overlayColor, 0),
|
||||
]}
|
||||
locations={[0, 0.8, 1]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 0}}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_xs,
|
||||
a.z_10,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
]}>
|
||||
<LiveIcon size="md" fill={textColor} />
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.leading_snug,
|
||||
a.font_bold,
|
||||
a.text_lg,
|
||||
a.pr_xl,
|
||||
{color: textColor},
|
||||
]}>
|
||||
{layout.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {useEffect, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {atoms as a, useBreakpoints, utils} from '#/alf'
|
||||
import {Link} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {
|
||||
type LiveEventFeed,
|
||||
type LiveEventFeedMetricContext,
|
||||
} from '#/features/liveEvents/types'
|
||||
|
||||
const roundedStyles = [a.rounded_lg, a.curve_continuous]
|
||||
|
||||
export function LiveEventFeedCardWide({
|
||||
feed,
|
||||
metricContext,
|
||||
}: {
|
||||
feed: LiveEventFeed
|
||||
metricContext: LiveEventFeedMetricContext
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
|
||||
const layout = feed.layouts.wide
|
||||
const overlayColor = layout.overlayColor
|
||||
const textColor = layout.textColor
|
||||
const url = useMemo(() => {
|
||||
// Validated in multiple places on the backend
|
||||
if (isBskyCustomFeedUrl(feed.url)) {
|
||||
return new URL(feed.url).pathname
|
||||
}
|
||||
return '/'
|
||||
}, [feed.url])
|
||||
|
||||
useEffect(() => {
|
||||
logger.metric('liveEvents:feedBanner:seen', {
|
||||
feed: feed.url,
|
||||
context: metricContext,
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={url}
|
||||
label={_(msg`Live event happening now: ${feed.title}`)}
|
||||
style={[a.w_full]}
|
||||
onPress={() => {
|
||||
logger.metric('liveEvents:feedBanner:click', {
|
||||
feed: feed.url,
|
||||
context: metricContext,
|
||||
})
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<View style={[roundedStyles, a.shadow_md, a.w_full]}>
|
||||
<View
|
||||
style={[
|
||||
a.align_start,
|
||||
roundedStyles,
|
||||
a.overflow_hidden,
|
||||
{
|
||||
aspectRatio: gtPhone ? 576 / 144 : 369 / 100,
|
||||
},
|
||||
]}>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={{uri: layout.image}}
|
||||
placeholder={{blurhash: layout.blurhash}}
|
||||
style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
|
||||
contentFit="cover"
|
||||
placeholderContentFit="cover"
|
||||
/>
|
||||
|
||||
<LinearGradient
|
||||
colors={[overlayColor, utils.alpha(overlayColor, 0)]}
|
||||
locations={[0, 1]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 0}}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.transition_opacity,
|
||||
{
|
||||
transitionDuration: '200ms',
|
||||
opacity: hovered || pressed ? 0.6 : 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<View style={[a.flex_1, a.justify_end]}>
|
||||
<LinearGradient
|
||||
colors={[overlayColor, utils.alpha(overlayColor, 0)]}
|
||||
locations={[0, 1]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 0}}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.z_10,
|
||||
gtPhone ? [a.pl_xl, a.pb_lg] : [a.pl_lg, a.pb_md],
|
||||
{paddingRight: 64},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.leading_snug,
|
||||
gtPhone ? a.text_xs : a.text_2xs,
|
||||
{color: textColor, opacity: 0.8},
|
||||
]}>
|
||||
{feed.preview ? (
|
||||
<Trans>Preview</Trans>
|
||||
) : (
|
||||
<Trans>Happening now</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.leading_snug,
|
||||
a.font_bold,
|
||||
gtPhone ? a.text_3xl : a.text_lg,
|
||||
{color: textColor},
|
||||
]}>
|
||||
{layout.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useCleanError} from '#/lib/hooks/useCleanError'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {atoms as a, web} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as toast from '#/components/Toast'
|
||||
import {Span, Text} from '#/components/Typography'
|
||||
import {useUpdateLiveEventPreferences} from '#/features/liveEvents/preferences'
|
||||
import {
|
||||
type LiveEventFeed,
|
||||
type LiveEventFeedMetricContext,
|
||||
} from '#/features/liveEvents/types'
|
||||
|
||||
export {useDialogControl} from '#/components/Dialog'
|
||||
|
||||
export function LiveEventFeedOptionsMenu({
|
||||
control,
|
||||
feed,
|
||||
metricContext,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
feed: LiveEventFeed
|
||||
metricContext: LiveEventFeedMetricContext
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Configure live event banner`)}
|
||||
style={[web({maxWidth: 400})]}>
|
||||
<Inner control={control} feed={feed} metricContext={metricContext} />
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function Inner({
|
||||
control,
|
||||
feed,
|
||||
metricContext,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
feed: LiveEventFeed
|
||||
metricContext: LiveEventFeedMetricContext
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {
|
||||
isPending,
|
||||
mutate: update,
|
||||
error: rawError,
|
||||
variables,
|
||||
} = useUpdateLiveEventPreferences({
|
||||
feed,
|
||||
metricContext,
|
||||
onUpdateSuccess({undoAction}) {
|
||||
toast.show(
|
||||
<toast.Outer>
|
||||
<toast.Icon />
|
||||
<toast.Text>
|
||||
<Trans>Your live event preferences have been updated.</Trans>
|
||||
</toast.Text>
|
||||
{undoAction && (
|
||||
<toast.Action
|
||||
label={_(msg`Undo`)}
|
||||
onPress={() => {
|
||||
if (undoAction) {
|
||||
update(undoAction)
|
||||
}
|
||||
}}>
|
||||
<Trans>Undo</Trans>
|
||||
</toast.Action>
|
||||
)}
|
||||
</toast.Outer>,
|
||||
{
|
||||
type: 'success',
|
||||
},
|
||||
)
|
||||
|
||||
/*
|
||||
* If there is no `undoAction`, it means that the action was already
|
||||
* undone, and therefore the menu would have been closed prior to the
|
||||
* undo happening.
|
||||
*/
|
||||
if (undoAction) {
|
||||
control.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
const cleanError = useCleanError()
|
||||
const error = rawError ? cleanError(rawError) : undefined
|
||||
|
||||
const isHidingFeed = variables?.type === 'hideFeed' && isPending
|
||||
const isHidingAllFeeds = variables?.type === 'toggleHideAllFeeds' && isPending
|
||||
|
||||
return (
|
||||
<View style={[a.gap_lg]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.text_2xl, a.font_semi_bold, a.leading_snug]}>
|
||||
<Trans>Live event options</Trans>
|
||||
</Text>
|
||||
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
Live events appear occasionally when something exciting is
|
||||
happening. If you'd like, you can hide this particular event, or all
|
||||
events for this placement in your app interface.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
If you choose to hide all events, you can always re-enable them from{' '}
|
||||
<Span style={[a.font_semi_bold]}>Settings → Content & Media</Span>.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_sm]}>
|
||||
<Button
|
||||
label={_(msg`Hide this event`)}
|
||||
size="large"
|
||||
color="primary_subtle"
|
||||
onPress={() => {
|
||||
update({type: 'hideFeed', id: feed.id})
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Hide this event</Trans>
|
||||
</ButtonText>
|
||||
{isHidingFeed && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Hide all events`)}
|
||||
size="large"
|
||||
color="secondary"
|
||||
onPress={() => {
|
||||
update({type: 'toggleHideAllFeeds'})
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Hide all events</Trans>
|
||||
</ButtonText>
|
||||
{isHidingAllFeeds && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
{isNative && (
|
||||
<Button
|
||||
label={_(msg`Cancel`)}
|
||||
size="large"
|
||||
color="secondary_inverted"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Cancel</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<Admonition type="error">
|
||||
{error.clean || error.raw || _(msg`An unknown error occurred.`)}
|
||||
</Admonition>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import * as SettingsList from '#/screens/Settings/components/SettingsList'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
|
||||
import {
|
||||
useLiveEventPreferences,
|
||||
useUpdateLiveEventPreferences,
|
||||
} from '#/features/liveEvents/preferences'
|
||||
|
||||
export function LiveEventFeedsSettingsToggle() {
|
||||
const {_} = useLingui()
|
||||
const {data: prefs} = useLiveEventPreferences()
|
||||
const {
|
||||
isPending,
|
||||
data: updatedPrefs,
|
||||
mutate: update,
|
||||
} = useUpdateLiveEventPreferences({
|
||||
metricContext: 'settings',
|
||||
})
|
||||
const hideAllFeeds = !!(updatedPrefs || prefs)?.hideAllFeeds
|
||||
|
||||
return (
|
||||
<Toggle.Item
|
||||
name="enable_live_event_banner"
|
||||
label={_(msg`Show live events in your Discover Feed`)}
|
||||
value={!hideAllFeeds}
|
||||
onChange={() => {
|
||||
if (!isPending) {
|
||||
update({type: 'toggleHideAllFeeds'})
|
||||
}
|
||||
}}>
|
||||
<SettingsList.Item>
|
||||
<SettingsList.ItemIcon icon={LiveIcon} />
|
||||
<SettingsList.ItemText>
|
||||
<Trans>Show live events in your Discover Feed</Trans>
|
||||
</SettingsList.ItemText>
|
||||
<Toggle.Platform />
|
||||
</SettingsList.Item>
|
||||
</Toggle.Item>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {LiveEventFeedCardCompact} from '#/features/liveEvents/components/LiveEventFeedCardCompact'
|
||||
import {useLiveEvents} from '#/features/liveEvents/context'
|
||||
|
||||
export function SidebarLiveEventFeedsBanner() {
|
||||
const events = useLiveEvents()
|
||||
return events.feeds.map(feed => (
|
||||
<LiveEventFeedCardCompact
|
||||
key={feed.id}
|
||||
feed={feed}
|
||||
metricContext="sidebar"
|
||||
/>
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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 {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(
|
||||
{
|
||||
// keep this, prefectching handles initial load
|
||||
staleTime: 1000 * 15,
|
||||
queryKey: liveEventsQueryKey,
|
||||
refetchInterval: 1000 * 60 * 5,
|
||||
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'
|
||||
@@ -0,0 +1,31 @@
|
||||
import {View, type ViewStyle} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
/**
|
||||
* The little blue dot used to nudge a user towards a certain feature. The dot
|
||||
* is absolutely positioned, and is intended to be configured by passing in
|
||||
* positional styles via `top`, `bottom`, `left`, and `right` props.
|
||||
*/
|
||||
export function Dot({
|
||||
top,
|
||||
bottom,
|
||||
left,
|
||||
right,
|
||||
}: Pick<ViewStyle, 'top' | 'bottom' | 'left' | 'right'>) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.absolute, {top, bottom, left, right}]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
{
|
||||
height: 8,
|
||||
width: 8,
|
||||
backgroundColor: t.palette.primary_500,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
|
||||
import {atoms as a, useTheme, utils, type ViewStyleProp} from '#/alf'
|
||||
|
||||
/**
|
||||
* A gradient overlay using the primary color at low opacity. This component is
|
||||
* absolutely positioned and intended to be composed within other components,
|
||||
* with optional styling allowed, such as adjusting border radius.
|
||||
*/
|
||||
export function Gradient({style}: ViewStyleProp) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={[
|
||||
utils.alpha(t.palette.primary_500, 0.2),
|
||||
utils.alpha(t.palette.primary_500, 0.1),
|
||||
]}
|
||||
locations={[0, 1]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 0}}
|
||||
style={[a.absolute, a.inset_0, style]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import {BAPP_CONFIG_URL} from '#/env'
|
||||
import {GEOLOCATION_URL} from '#/env'
|
||||
import {type Geolocation} from '#/geolocation/types'
|
||||
|
||||
export const GEOLOCATION_SERVICE_URL = `${BAPP_CONFIG_URL}/geolocation`
|
||||
export const GEOLOCATION_SERVICE_URL = `${GEOLOCATION_URL}/geolocation`
|
||||
|
||||
/**
|
||||
* Default geolocation config.
|
||||
|
||||
@@ -5,14 +5,20 @@ import {type Geolocation} from '#/geolocation/types'
|
||||
const localEnabled = false
|
||||
export const enabled = IS_DEV && (localEnabled || aaDebug.geolocation)
|
||||
export const geolocation: Geolocation = aaDebug.geolocation ?? {
|
||||
countryCode: 'AU',
|
||||
regionCode: undefined,
|
||||
}
|
||||
export const deviceGeolocation: Geolocation = aaDebug.deviceGeolocation ?? {
|
||||
countryCode: 'AU',
|
||||
regionCode: undefined,
|
||||
countryCode: 'US',
|
||||
regionCode: 'TX',
|
||||
}
|
||||
|
||||
const deviceLocalEnabled = false
|
||||
export const deviceGeolocation: Geolocation | undefined =
|
||||
aaDebug.deviceGeolocation ||
|
||||
(deviceLocalEnabled
|
||||
? {
|
||||
countryCode: 'US',
|
||||
regionCode: 'TX',
|
||||
}
|
||||
: undefined)
|
||||
|
||||
export async function resolve<T>(data: T) {
|
||||
await new Promise(y => setTimeout(y, 500)) // simulate network
|
||||
return data
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {useCallback, useEffect, useRef} from 'react'
|
||||
import {Platform} from 'react-native'
|
||||
import * as Location from 'expo-location'
|
||||
import {createPermissionHook} from 'expo-modules-core'
|
||||
|
||||
@@ -46,7 +47,8 @@ const useForegroundPermissions = createPermissionHook({
|
||||
})
|
||||
|
||||
export async function getDeviceGeolocation(): Promise<Geolocation> {
|
||||
if (debug.enabled) return debug.resolve(debug.deviceGeolocation)
|
||||
if (debug.enabled && debug.deviceGeolocation)
|
||||
return debug.resolve(debug.deviceGeolocation)
|
||||
|
||||
try {
|
||||
const geocode = await Location.getCurrentPositionAsync()
|
||||
@@ -56,6 +58,18 @@ export async function getDeviceGeolocation(): Promise<Geolocation> {
|
||||
})
|
||||
const location = locations.at(0)
|
||||
const normalized = location ? normalizeDeviceLocation(location) : undefined
|
||||
if (normalized?.regionCode && normalized.regionCode.length > 5) {
|
||||
/*
|
||||
* We want short codes only, and we're still seeing some full names here.
|
||||
* 5 is just a heuristic for a region that is probably not formatted as a
|
||||
* short code.
|
||||
*/
|
||||
logger.error('getDeviceGeolocation: invalid regionCode', {
|
||||
os: Platform.OS,
|
||||
version: Platform.Version,
|
||||
regionCode: normalized.regionCode,
|
||||
})
|
||||
}
|
||||
return {
|
||||
countryCode: normalized?.countryCode ?? undefined,
|
||||
regionCode: normalized?.regionCode ?? undefined,
|
||||
@@ -142,3 +156,8 @@ export function useSyncDeviceGeolocationOnStartup(
|
||||
})
|
||||
}, [status, sync])
|
||||
}
|
||||
|
||||
export function useIsDeviceGeolocationGranted() {
|
||||
const [status] = useForegroundPermissions()
|
||||
return status?.granted === true
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ import {type Geolocation} from '#/geolocation/types'
|
||||
import {mergeGeolocations} from '#/geolocation/util'
|
||||
import {device, useStorage} from '#/storage'
|
||||
|
||||
export {useRequestDeviceGeolocation} from '#/geolocation/device'
|
||||
export {
|
||||
useIsDeviceGeolocationGranted,
|
||||
useRequestDeviceGeolocation,
|
||||
} from '#/geolocation/device'
|
||||
export {resolve} from '#/geolocation/service'
|
||||
export * from '#/geolocation/types'
|
||||
|
||||
|
||||
+24
-16
@@ -7,7 +7,7 @@ import {
|
||||
import {isAfter, parseISO} from 'date-fns'
|
||||
|
||||
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useLiveNowConfig} from '#/state/service-config'
|
||||
import {type LiveNowConfig, useLiveNowConfig} from '#/state/service-config'
|
||||
import {useTickEveryMinute} from '#/state/shell'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
@@ -19,15 +19,27 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
|
||||
return useMemo(() => {
|
||||
tick! // revalidate every minute
|
||||
|
||||
if (
|
||||
shadowed &&
|
||||
'status' in shadowed &&
|
||||
shadowed.status &&
|
||||
validateStatus(shadowed.did, shadowed.status, config) &&
|
||||
isStatusStillActive(shadowed.status.expiresAt)
|
||||
) {
|
||||
if (shadowed && 'status' in shadowed && shadowed.status) {
|
||||
const isValid = validateStatus(shadowed.status, config)
|
||||
const isDisabled = shadowed.status.isDisabled || false
|
||||
const isActive = isStatusStillActive(shadowed.status.expiresAt)
|
||||
if (isValid && !isDisabled && isActive) {
|
||||
return {
|
||||
uri: shadowed.status.uri,
|
||||
cid: shadowed.status.cid,
|
||||
isDisabled: false,
|
||||
isActive: true,
|
||||
status: 'app.bsky.actor.status#live',
|
||||
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
|
||||
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
|
||||
record: shadowed.status.record,
|
||||
} satisfies AppBskyActorDefs.StatusView
|
||||
}
|
||||
return {
|
||||
isActive: true,
|
||||
uri: shadowed.status.uri,
|
||||
cid: shadowed.status.cid,
|
||||
isDisabled,
|
||||
isActive: false,
|
||||
status: 'app.bsky.actor.status#live',
|
||||
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
|
||||
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
|
||||
@@ -36,6 +48,7 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
|
||||
} else {
|
||||
return {
|
||||
status: '',
|
||||
isDisabled: false,
|
||||
isActive: false,
|
||||
record: {},
|
||||
} satisfies AppBskyActorDefs.StatusView
|
||||
@@ -52,19 +65,14 @@ export function isStatusStillActive(timeStr: string | undefined) {
|
||||
}
|
||||
|
||||
export function validateStatus(
|
||||
did: string,
|
||||
status: AppBskyActorDefs.StatusView,
|
||||
config: {did: string; domains: string[]}[],
|
||||
config: LiveNowConfig,
|
||||
) {
|
||||
if (status.status !== 'app.bsky.actor.status#live') return false
|
||||
const sources = config.find(cfg => cfg.did === did)
|
||||
if (!sources) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
if (AppBskyEmbedExternal.isView(status.embed)) {
|
||||
const url = new URL(status.embed.external.uri)
|
||||
return sources.domains.includes(url.hostname)
|
||||
return config.allowedDomains.has(url.hostname)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -241,6 +241,10 @@ export const BLUESKY_MOD_SERVICE_HEADERS = {
|
||||
'atproto-proxy': `${BSKY_LABELER_DID}#atproto_labeler`,
|
||||
}
|
||||
|
||||
export const BLUESKY_NOTIF_SERVICE_HEADERS = {
|
||||
'atproto-proxy': `${BLUESKY_PROXY_DID}#bsky_notif`,
|
||||
}
|
||||
|
||||
export const webLinks = {
|
||||
tos: `https://bsky.social/about/support/tos`,
|
||||
privacy: `https://bsky.social/about/support/privacy-policy`,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user