diff --git a/.env.example b/.env.example index 96a1548d66..e90541cabb 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.eslintrc.js b/.eslintrc.js index 37ed895aa4..16e844a050 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -35,6 +35,7 @@ module.exports = { 'Admonition', 'Admonition.Admonition', 'Toast.Action', + 'toast.Action', 'AgeAssuranceAdmonition', 'Span', 'StackedButton', diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..e1a7d174ed --- /dev/null +++ b/CLAUDE.md @@ -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 ( + + + Hello + + + ) +} +``` + +### 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 ( + <> + + + + {/* Native drag handle */} + + + Title + + + Dialog content here + + + + + + ) +} +``` + +### 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 ( + + + {({props}) => ( + + )} + + + + + + + Edit + + + + Delete + + + + + ) +} +``` + +### Button Component + +```tsx +import {Button, ButtonText, ButtonIcon} from '#/components/Button' + +// Solid primary button (most common) + + +// With icon + + +// Icon-only button + + +// Ghost variant (deprecated - use color prop) + +``` + +**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' + +

Heading

+

Paragraph text with default styling.

+Custom text + +// For text with emoji, add the emoji prop +Hello! 👋 +``` + +### TextField + +```tsx +import * as TextField from '#/components/forms/TextField' + +Email + + + + +``` + +## 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 ( + + Welcome to Bluesky + + ) +} +``` + +**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 ( + + ) +} +``` + +### Session State + +```tsx +import {useSession, useAgent} from '#/state/session' + +function MyComponent() { + const {hasSession, currentAccount} = useSession() + const agent = useAgent() + + if (!hasSession) { + return + } + + // 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 + +export function ProfileScreen({route, navigation}: Props) { + const {name} = route.params // Type-safe params + + return ( + + {/* Screen content */} + + ) +} + +// 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 + + +// Avoid when possible - controlled (can cause performance issues) + +``` + +### 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 `` + +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` | diff --git a/assets/images/live_now_beta.webp b/assets/images/live_now_beta.webp new file mode 100644 index 0000000000..7b5b074835 Binary files /dev/null and b/assets/images/live_now_beta.webp differ diff --git a/package.json b/package.json index 0030297734..d94a49874f 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.18.14", + "@atproto/api": "^0.18.15", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@bsky.app/alf": "^0.1.6", @@ -284,6 +284,7 @@ "@react-native/babel-preset": "0.81.5", "@react-native/normalize-colors": "0.81.5", "**/@expo/image-utils": "0.8.7", + "**/@react-native-async-storage/async-storage": "2.2.0", "**/expo-constants": "18.0.8", "**/expo-device": "7.1.4", "**/zod": "3.23.8", diff --git a/patches/expo-font+14.0.10.patch b/patches/expo-font+14.0.10.patch new file mode 100644 index 0000000000..ac7526e23d --- /dev/null +++ b/patches/expo-font+14.0.10.patch @@ -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) + } + diff --git a/src/App.native.tsx b/src/App.native.tsx index 34c3cc204c..2c4d6fa413 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -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() { - - - - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index 956c52005c..460d9ff174 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -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() { - - - - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/alf/index.tsx b/src/alf/index.tsx index eed3fcbeb2..3aff9ddb57 100644 --- a/src/alf/index.tsx +++ b/src/alf/index.tsx @@ -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' diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index ee2c0f76c7..b6d47fd3b4 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -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, }, ]} diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx index c5e6200527..1fcedc6916 100644 --- a/src/components/FeedCard.tsx +++ b/src/components/FeedCard.tsx @@ -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) {
- +
@@ -118,14 +124,40 @@ export function AvatarPlaceholder({size = 40}: Omit) { 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 ( + {uri && activeLiveEvents.has(uri) && ( + + + + Happening now + + + )} @@ -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 ( ) diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx index 242449bf49..339b26350c 100644 --- a/src/components/Menu/index.web.tsx +++ b/src/components/Menu/index.web.tsx @@ -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) { diff --git a/src/components/Menu/types.ts b/src/components/Menu/types.ts index e9ee082f4c..a5fce102ba 100644 --- a/src/components/Menu/types.ts +++ b/src/components/Menu/types.ts @@ -107,6 +107,7 @@ export type ItemTextProps = React.PropsWithChildren export type ItemIconProps = React.PropsWithChildren<{ icon: React.ComponentType position?: 'left' | 'right' + fill?: (props: {disabled: boolean}) => string }> export type GroupProps = React.PropsWithChildren diff --git a/src/components/Post/Embed/FeedEmbed.tsx b/src/components/Post/Embed/FeedEmbed.tsx index 26ff9b5298..a726c313e1 100644 --- a/src/components/Post/Embed/FeedEmbed.tsx +++ b/src/components/Post/Embed/FeedEmbed.tsx @@ -17,16 +17,16 @@ export function FeedEmbed({ return ( + style={[a.border, t.atoms.border_contrast_low, a.p_sm, a.rounded_md]}> - + - ) diff --git a/src/components/PostControls/BookmarkButton.tsx b/src/components/PostControls/BookmarkButton.tsx index f729515202..ac391a1ae9 100644 --- a/src/components/PostControls/BookmarkButton.tsx +++ b/src/components/PostControls/BookmarkButton.tsx @@ -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( @@ -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( diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx index f8b410be97..ce93e81ab1 100644 --- a/src/components/PostControls/PostMenu/PostMenuItems.tsx +++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx @@ -98,6 +98,7 @@ let PostMenuItems = ({ richText, threadgateRecord, onShowLess, + logContext, }: { testID: string post: Shadow @@ -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, diff --git a/src/components/PostControls/PostMenu/index.tsx b/src/components/PostControls/PostMenu/index.tsx index 950bc4f6d9..f418587a5c 100644 --- a/src/components/PostControls/PostMenu/index.tsx +++ b/src/components/PostControls/PostMenu/index.tsx @@ -29,6 +29,7 @@ let PostMenuButton = ({ threadgateRecord, onShowLess, hitSlop, + logContext, }: { testID: string post: Shadow @@ -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} /> )} diff --git a/src/components/PostControls/ShareMenu/index.tsx b/src/components/PostControls/ShareMenu/index.tsx index 6127ca41db..3fd9583113 100644 --- a/src/components/PostControls/ShareMenu/index.tsx +++ b/src/components/PostControls/ShareMenu/index.tsx @@ -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 @@ -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 = () => { diff --git a/src/components/PostControls/index.tsx b/src/components/PostControls/index.tsx index ec0016815f..45688c2617 100644 --- a/src/components/PostControls/index.tsx +++ b/src/components/PostControls/index.tsx @@ -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} /> diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 9908679c57..eca0d7c635 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -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]+)|((?[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/i export type RichTextProps = TextStyleProp & Pick & { @@ -115,7 +118,8 @@ export function RichText({ , ) } 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( diff --git a/src/components/dialogs/nuxs/LiveNowBetaDialog.tsx b/src/components/dialogs/nuxs/LiveNowBetaDialog.tsx new file mode 100644 index 0000000000..b081322c44 --- /dev/null +++ b/src/components/dialogs/nuxs/LiveNowBetaDialog.tsx @@ -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 ( + + + + + + + + + + Beta Feature + + + + + + {_( + + + + + + + Show when you’re live + + + + Streaming on Twitch? Set your live status on Bluesky to add a + badge to your avatar. Tapping it takes people straight to your + stream. + + + + + {!isWeb && ( + + )} + + + + + + ) +} diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx index 2ab319fafd..eadadeb8eb 100644 --- a/src/components/dialogs/nuxs/index.tsx +++ b/src/components/dialogs/nuxs/index.tsx @@ -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 ( {/*For example, activeNux === Nux.NeueTypography && */} - {activeNux === Nux.FindContactsAnnouncement && ( - - )} + {activeNux === Nux.LiveNowBetaDialog && } ) } diff --git a/src/components/interstitials/Trending.tsx b/src/components/interstitials/Trending.tsx index 3d47385ee8..830dfb3b66 100644 --- a/src/components/interstitials/Trending.tsx +++ b/src/components/interstitials/Trending.tsx @@ -41,7 +41,7 @@ export function Inner() { }, [setTrendingDisabled]) return error || noTopics ? null : ( - + {(liveLinkError || linkMetaError) && ( - - - - {liveLinkError ? ( - This is not a valid link - ) : ( - cleanError(linkMetaError) - )} - - + + {liveLinkError ? ( + This is not a valid link + ) : ( + cleanError(linkMetaError) + )} + )} diff --git a/src/components/live/GoLiveDialog.tsx b/src/components/live/GoLiveDialog.tsx index 44c604cde7..9d866fd6a5 100644 --- a/src/components/live/GoLiveDialog.tsx +++ b/src/components/live/GoLiveDialog.tsx @@ -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}) { /> - {(liveLinkError || linkMetaError) && ( - - - - {liveLinkError ? ( - This is not a valid link - ) : ( - cleanError(linkMetaError) - )} - - + {liveLinkError || linkMetaError ? ( + + {liveLinkError ? ( + This is not a valid link + ) : ( + cleanError(linkMetaError) + )} + + ) : ( + + + The following services are enabled for your account:{' '} + {allowedServices} + + )} diff --git a/src/components/live/queries.ts b/src/components/live/queries.ts index 19cb54ebf2..7db944d06d 100644 --- a/src/components/live/queries.ts +++ b/src/components/live/queries.ts @@ -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) diff --git a/src/components/live/utils.ts b/src/components/live/utils.ts index 6b4267cb01..10b0ba8f90 100644 --- a/src/components/live/utils.ts +++ b/src/components/live/utils.ts @@ -35,3 +35,28 @@ export function useDebouncedValue(val: T, delayMs: number): T { return prev } + +const serviceUrlToNameMap: Record = { + '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) { + const names = Array.from( + new Set(Array.from(domains.values()).map(d => serviceUrlToNameMap[d] || d)), + ) + return { + names, + formatted: names.join(', '), + } +} diff --git a/src/env/common.ts b/src/env/common.ts index b0d75d403b..04e98c49cb 100644 --- a/src/env/common.ts +++ b/src/env/common.ts @@ -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 diff --git a/src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx b/src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx new file mode 100644 index 0000000000..de8bc81e7a --- /dev/null +++ b/src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx @@ -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 + } else { + // no feed, no trending + return null + } + } + + // On desktop, we show in the sidebar + if (rightNavVisible) return null + + return events.feeds.map(feed => ) +} + +function Inner({feed}: {feed: LiveEventFeed}) { + const {_} = useLingui() + const optionsMenuControl = useDialogControl() + const layout = feed.layouts.wide + + return ( + <> + + + + + + + + + + + ) +} diff --git a/src/features/liveEvents/components/ExploreScreenLiveEventFeedsBanner.tsx b/src/features/liveEvents/components/ExploreScreenLiveEventFeedsBanner.tsx new file mode 100644 index 0000000000..bf80ed6a2e --- /dev/null +++ b/src/features/liveEvents/components/ExploreScreenLiveEventFeedsBanner.tsx @@ -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 => ( + + + + )) +} diff --git a/src/features/liveEvents/components/LiveEventFeedCardCompact.tsx b/src/features/liveEvents/components/LiveEventFeedCardCompact.tsx new file mode 100644 index 0000000000..69c24b2dc5 --- /dev/null +++ b/src/features/liveEvents/components/LiveEventFeedCardCompact.tsx @@ -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 ( + { + logger.metric('liveEvents:feedBanner:click', { + feed: feed.url, + context: metricContext, + }) + }}> + {({hovered, pressed}) => ( + + + + + + + + + + + + + {layout.title} + + + + + + )} + + ) +} diff --git a/src/features/liveEvents/components/LiveEventFeedCardWide.tsx b/src/features/liveEvents/components/LiveEventFeedCardWide.tsx new file mode 100644 index 0000000000..1ed3a49df2 --- /dev/null +++ b/src/features/liveEvents/components/LiveEventFeedCardWide.tsx @@ -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 ( + { + logger.metric('liveEvents:feedBanner:click', { + feed: feed.url, + context: metricContext, + }) + }}> + {({hovered, pressed}) => ( + + + + + + + + + + + + {feed.preview ? ( + Preview + ) : ( + Happening now + )} + + + {layout.title} + + + + + + )} + + ) +} diff --git a/src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx b/src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx new file mode 100644 index 0000000000..b2f50840a9 --- /dev/null +++ b/src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx @@ -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 ( + + + + + + + + ) +} + +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( + + + + Your live event preferences have been updated. + + {undoAction && ( + { + if (undoAction) { + update(undoAction) + } + }}> + Undo + + )} + , + { + 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 ( + + + + Live event options + + + + + 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. + + + + + + If you choose to hide all events, you can always re-enable them from{' '} + Settings → Content & Media. + + + + + + + + {isNative && ( + + )} + + + {error && ( + + {error.clean || error.raw || _(msg`An unknown error occurred.`)} + + )} + + ) +} diff --git a/src/features/liveEvents/components/LiveEventFeedsSettingsToggle.tsx b/src/features/liveEvents/components/LiveEventFeedsSettingsToggle.tsx new file mode 100644 index 0000000000..35d4039ce1 --- /dev/null +++ b/src/features/liveEvents/components/LiveEventFeedsSettingsToggle.tsx @@ -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 ( + { + if (!isPending) { + update({type: 'toggleHideAllFeeds'}) + } + }}> + + + + Show live events in your Discover Feed + + + + + ) +} diff --git a/src/features/liveEvents/components/SidebarLiveEventFeedsBanner.tsx b/src/features/liveEvents/components/SidebarLiveEventFeedsBanner.tsx new file mode 100644 index 0000000000..25a5db8c8c --- /dev/null +++ b/src/features/liveEvents/components/SidebarLiveEventFeedsBanner.tsx @@ -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 => ( + + )) +} diff --git a/src/features/liveEvents/context.tsx b/src/features/liveEvents/context.tsx new file mode 100644 index 0000000000..fa2aba2260 --- /dev/null +++ b/src/features/liveEvents/context.tsx @@ -0,0 +1,110 @@ +import {createContext, useContext, useMemo} from 'react' +import {QueryClient, useQuery} from '@tanstack/react-query' + +import {useIsBskyTeam} from '#/lib/hooks/useIsBskyTeam' +import { + convertBskyAppUrlIfNeeded, + isBskyCustomFeedUrl, + makeRecordUri, +} from '#/lib/strings/url-helpers' +import {IS_DEV, LIVE_EVENTS_URL} from '#/env' +import {useLiveEventPreferences} from '#/features/liveEvents/preferences' +import {type LiveEventsWorkerResponse} from '#/features/liveEvents/types' +import {useDevMode} from '#/storage/hooks/dev-mode' + +const qc = new QueryClient() +const liveEventsQueryKey = ['live-events'] + +export const DEFAULT_LIVE_EVENTS = { + feeds: [], +} + +async function fetchLiveEvents(): Promise { + 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(DEFAULT_LIVE_EVENTS) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [isDevMode] = useDevMode() + const isBskyTeam = useIsBskyTeam() + const {data} = useQuery( + { + staleTime: IS_DEV ? 5e3 : 1000 * 60, + queryKey: liveEventsQueryKey, + async queryFn() { + return fetchLiveEvents() + }, + }, + qc, + ) + + const ctx = useMemo(() => { + if (!data) return DEFAULT_LIVE_EVENTS + const feeds = data.feeds.filter(f => { + if (f.preview && !isBskyTeam) return false + return true + }) + return { + ...data, + // only one at a time for now, unless bsky team and dev mode + feeds: isBskyTeam && isDevMode ? feeds : feeds.slice(0, 1), + } + }, [data, isBskyTeam, isDevMode]) + + return {children} +} + +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() + }), + ) +} diff --git a/src/features/liveEvents/preferences.ts b/src/features/liveEvents/preferences.ts new file mode 100644 index 0000000000..2f7af094a8 --- /dev/null +++ b/src/features/liveEvents/preferences.ts @@ -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! + }, + }) +} diff --git a/src/features/liveEvents/types.ts b/src/features/liveEvents/types.ts new file mode 100644 index 0000000000..8bc88c448c --- /dev/null +++ b/src/features/liveEvents/types.ts @@ -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 +} + +export type LiveEventsWorkerResponse = { + feeds: LiveEventFeed[] +} + +export type LiveEventFeedMetricContext = + | 'explore' + | 'discover' + | 'sidebar' + | 'settings' diff --git a/src/features/nuxs/components/Dot.tsx b/src/features/nuxs/components/Dot.tsx new file mode 100644 index 0000000000..7eb82bf60f --- /dev/null +++ b/src/features/nuxs/components/Dot.tsx @@ -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) { + const t = useTheme() + return ( + + + + ) +} diff --git a/src/features/nuxs/components/Gradient.tsx b/src/features/nuxs/components/Gradient.tsx new file mode 100644 index 0000000000..5da8b4aca3 --- /dev/null +++ b/src/features/nuxs/components/Gradient.tsx @@ -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 ( + + ) +} diff --git a/src/geolocation/const.ts b/src/geolocation/const.ts index b12f37140f..653e829bad 100644 --- a/src/geolocation/const.ts +++ b/src/geolocation/const.ts @@ -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. diff --git a/src/lib/actor-status.ts b/src/lib/actor-status.ts index 31e532eaa0..7c18b5dc66 100644 --- a/src/lib/actor-status.ts +++ b/src/lib/actor-status.ts @@ -7,7 +7,7 @@ import { import {isAfter, parseISO} from 'date-fns' import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' -import {useLiveNowConfig} from '#/state/service-config' +import {type LiveNowConfig, useLiveNowConfig} from '#/state/service-config' import {useTickEveryMinute} from '#/state/shell' import type * as bsky from '#/types/bsky' @@ -20,7 +20,7 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) { tick! // revalidate every minute if (shadowed && 'status' in shadowed && shadowed.status) { - const isValid = validateStatus(shadowed.did, shadowed.status, config) + const isValid = validateStatus(shadowed.status, config) const isDisabled = shadowed.status.isDisabled || false const isActive = isStatusStillActive(shadowed.status.expiresAt) if (isValid && !isDisabled && isActive) { @@ -65,19 +65,14 @@ export function isStatusStillActive(timeStr: string | undefined) { } export function validateStatus( - did: string, status: AppBskyActorDefs.StatusView, - config: {did: string; domains: string[]}[], + config: LiveNowConfig, ) { if (status.status !== 'app.bsky.actor.status#live') return false - const sources = config.find(cfg => cfg.did === did) - if (!sources) { - return false - } try { if (AppBskyEmbedExternal.isView(status.embed)) { const url = new URL(status.embed.external.uri) - return sources.domains.includes(url.hostname) + return config.allowedDomains.has(url.hostname) } else { return false } diff --git a/src/lib/hooks/useIsBskyTeam.ts b/src/lib/hooks/useIsBskyTeam.ts new file mode 100644 index 0000000000..d653e7fd5e --- /dev/null +++ b/src/lib/hooks/useIsBskyTeam.ts @@ -0,0 +1,8 @@ +import {useMemo} from 'react' + +import {useGate} from '#/lib/statsig/statsig' + +export function useIsBskyTeam() { + const gate = useGate() + return useMemo(() => gate('is_bsky_team_member'), [gate]) +} diff --git a/src/lib/react-query.tsx b/src/lib/react-query.tsx index fe3ec6f4c7..e788bf410c 100644 --- a/src/lib/react-query.tsx +++ b/src/lib/react-query.tsx @@ -1,4 +1,4 @@ -import {useRef, useState} from 'react' +import {useEffect, useRef, useState} from 'react' import {AppState, type AppStateStatus} from 'react-native' import AsyncStorage from '@react-native-async-storage/async-storage' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' @@ -9,9 +9,15 @@ import { } from '@tanstack/react-query-persist-client' import type React from 'react' -import {isNative} from '#/platform/detection' +import {isNative, isWeb} from '#/platform/detection' import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events' +declare global { + interface Window { + __TANSTACK_QUERY_CLIENT__: import('@tanstack/query-core').QueryClient + } +} + // any query keys in this array will be persisted to AsyncStorage export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info' const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot] @@ -180,6 +186,11 @@ function QueryProviderInner({ dehydrateOptions, } }) + useEffect(() => { + if (isWeb) { + window.__TANSTACK_QUERY_CLIENT__ = queryClient + } + }, [queryClient]) return ( moderation settings." msgstr "" @@ -2316,7 +2334,7 @@ msgstr "" msgid "Contacts removed" msgstr "" -#: src/screens/Settings/ContentAndMediaSettings.tsx:51 +#: src/screens/Settings/ContentAndMediaSettings.tsx:52 msgid "Content & Media" msgstr "" @@ -2419,7 +2437,7 @@ msgstr "" #: src/components/dms/MessageContextMenu.tsx:57 #: src/components/PostControls/DiscoverDebug.tsx:36 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:235 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:249 #: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:72 #: src/lib/sharing.ts:25 #: src/lib/sharing.ts:41 @@ -2443,8 +2461,8 @@ msgstr "" msgid "Copy App Password" msgstr "" -#: src/view/com/profile/ProfileMenu.tsx:447 -#: src/view/com/profile/ProfileMenu.tsx:450 +#: src/view/com/profile/ProfileMenu.tsx:491 +#: src/view/com/profile/ProfileMenu.tsx:494 msgid "Copy at:// URI" msgstr "" @@ -2459,8 +2477,8 @@ msgid "Copy code" msgstr "" #: src/screens/Settings/components/ChangeHandleDialog.tsx:502 -#: src/view/com/profile/ProfileMenu.tsx:456 -#: src/view/com/profile/ProfileMenu.tsx:459 +#: src/view/com/profile/ProfileMenu.tsx:500 +#: src/view/com/profile/ProfileMenu.tsx:503 msgid "Copy DID" msgstr "" @@ -2489,8 +2507,8 @@ msgstr "" msgid "Copy link to post" msgstr "" -#: src/view/com/profile/ProfileMenu.tsx:252 -#: src/view/com/profile/ProfileMenu.tsx:263 +#: src/view/com/profile/ProfileMenu.tsx:269 +#: src/view/com/profile/ProfileMenu.tsx:280 msgid "Copy link to profile" msgstr "" @@ -2508,8 +2526,8 @@ msgstr "" msgid "Copy post at:// URI" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:484 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:486 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:510 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:512 msgid "Copy post text" msgstr "" @@ -2770,7 +2788,7 @@ msgid "Default icons" msgstr "" #: src/components/dms/MessageContextMenu.tsx:202 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:736 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:762 #: src/screens/Messages/components/ChatStatusInfo.tsx:55 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:280 #: src/screens/Settings/AppPasswords.tsx:212 @@ -2842,8 +2860,8 @@ msgstr "" msgid "Delete my account" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:717 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:719 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:743 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:745 #: src/view/com/composer/Composer.tsx:969 msgid "Delete post" msgstr "" @@ -2861,7 +2879,7 @@ msgstr "" msgid "Delete this list?" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:731 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:757 msgid "Delete this post?" msgstr "" @@ -2896,12 +2914,12 @@ msgstr "" msgid "Descriptive alt text" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:621 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:631 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:647 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:657 msgid "Detach quote" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:767 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:793 msgid "Detach quote post?" msgstr "" @@ -2997,12 +3015,12 @@ msgstr "" msgid "Discover new custom feeds" msgstr "" -#: src/screens/Search/Explore.tsx:431 +#: src/screens/Search/Explore.tsx:436 #: src/view/screens/Feeds.tsx:730 msgid "Discover New Feeds" msgstr "" -#: src/components/Dialog/index.tsx:373 +#: src/components/Dialog/index.tsx:379 msgid "Dismiss" msgstr "" @@ -3116,7 +3134,7 @@ msgstr "" msgid "Double tap or long press the message to add a reaction" msgstr "" -#: src/components/Dialog/index.tsx:374 +#: src/components/Dialog/index.tsx:380 msgid "Double tap to close the dialog" msgstr "" @@ -3214,8 +3232,8 @@ msgstr "" msgid "Edit image" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:698 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:711 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:724 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:737 msgid "Edit interaction settings" msgstr "" @@ -3229,8 +3247,8 @@ msgstr "" msgid "Edit list details" msgstr "" -#: src/view/com/profile/ProfileMenu.tsx:337 -#: src/view/com/profile/ProfileMenu.tsx:349 +#: src/view/com/profile/ProfileMenu.tsx:354 +#: src/view/com/profile/ProfileMenu.tsx:374 msgid "Edit live status" msgstr "" @@ -3394,13 +3412,13 @@ msgstr "" msgid "Enable this source only" msgstr "" -#: src/screens/Settings/ContentAndMediaSettings.tsx:132 -#: src/screens/Settings/ContentAndMediaSettings.tsx:146 +#: src/screens/Settings/ContentAndMediaSettings.tsx:133 +#: src/screens/Settings/ContentAndMediaSettings.tsx:147 msgid "Enable trending topics" msgstr "" -#: src/screens/Settings/ContentAndMediaSettings.tsx:153 -#: src/screens/Settings/ContentAndMediaSettings.tsx:167 +#: src/screens/Settings/ContentAndMediaSettings.tsx:155 +#: src/screens/Settings/ContentAndMediaSettings.tsx:169 msgid "Enable trending videos in your Discover feed" msgstr "" @@ -3596,7 +3614,7 @@ msgstr "" msgid "Expires in {0}" msgstr "" -#: src/components/live/EditLiveDialog.tsx:131 +#: src/components/live/EditLiveDialog.tsx:130 msgid "Expires in {0} at {1}" msgstr "" @@ -3630,8 +3648,8 @@ msgstr "" msgid "Export My Data" msgstr "" -#: src/screens/Settings/ContentAndMediaSettings.tsx:84 -#: src/screens/Settings/ContentAndMediaSettings.tsx:87 +#: src/screens/Settings/ContentAndMediaSettings.tsx:85 +#: src/screens/Settings/ContentAndMediaSettings.tsx:88 msgid "External media" msgstr "" @@ -3695,7 +3713,7 @@ msgstr "" msgid "Failed to delete message" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:204 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:206 msgid "Failed to delete post, please try again" msgstr "" @@ -3724,10 +3742,10 @@ msgstr "" msgid "Failed to load conversations" msgstr "" -#: src/screens/Search/Explore.tsx:467 -#: src/screens/Search/Explore.tsx:519 -#: src/screens/Search/Explore.tsx:557 -#: src/screens/Search/Explore.tsx:596 +#: src/screens/Search/Explore.tsx:472 +#: src/screens/Search/Explore.tsx:524 +#: src/screens/Search/Explore.tsx:562 +#: src/screens/Search/Explore.tsx:601 msgid "Failed to load feeds preferences" msgstr "" @@ -3757,14 +3775,14 @@ msgstr "" msgid "Failed to load preference." msgstr "" -#: src/screens/Search/Explore.tsx:460 -#: src/screens/Search/Explore.tsx:512 -#: src/screens/Search/Explore.tsx:550 -#: src/screens/Search/Explore.tsx:589 +#: src/screens/Search/Explore.tsx:465 +#: src/screens/Search/Explore.tsx:517 +#: src/screens/Search/Explore.tsx:555 +#: src/screens/Search/Explore.tsx:594 msgid "Failed to load suggested feeds" msgstr "" -#: src/screens/Search/Explore.tsx:370 +#: src/screens/Search/Explore.tsx:375 msgid "Failed to load suggested follows" msgstr "" @@ -3831,7 +3849,7 @@ msgstr "" msgid "Failed to submit appeal, please try again." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:224 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:238 msgid "Failed to toggle thread mute, please try again" msgstr "" @@ -3848,7 +3866,7 @@ msgstr "" msgid "Failed to update email, please try again." msgstr "" -#: src/components/FeedCard.tsx:285 +#: src/components/FeedCard.tsx:317 msgid "Failed to update feeds" msgstr "" @@ -3887,7 +3905,7 @@ msgstr "" msgid "Feed" msgstr "" -#: src/components/FeedCard.tsx:139 +#: src/components/FeedCard.tsx:171 #: src/view/com/feeds/FeedSourceCard.tsx:150 msgid "Feed by {0}" msgstr "" @@ -3912,14 +3930,14 @@ msgstr "" msgid "Feed unavailable" msgstr "" -#: src/view/shell/desktop/RightNav.tsx:104 -#: src/view/shell/desktop/RightNav.tsx:105 +#: src/view/shell/desktop/RightNav.tsx:107 +#: src/view/shell/desktop/RightNav.tsx:108 #: src/view/shell/Drawer.tsx:368 msgid "Feedback" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:276 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:294 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:296 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:320 msgctxt "toast" msgid "Feedback sent to feed operator" msgstr "" @@ -3939,7 +3957,7 @@ msgstr "" msgid "Feeds are custom algorithms that users build with a little coding expertise. <0>See this guide for more information." msgstr "" -#: src/components/FeedCard.tsx:282 +#: src/components/FeedCard.tsx:314 #: src/screens/SavedFeeds.tsx:90 msgctxt "toast" msgid "Feeds updated!" @@ -4099,8 +4117,8 @@ msgstr "" msgid "Follow 7 accounts" msgstr "" -#: src/view/com/profile/ProfileMenu.tsx:293 -#: src/view/com/profile/ProfileMenu.tsx:304 +#: src/view/com/profile/ProfileMenu.tsx:310 +#: src/view/com/profile/ProfileMenu.tsx:321 msgid "Follow account" msgstr "" @@ -4191,8 +4209,8 @@ msgstr "" msgid "Following {handle}" msgstr "" -#: src/screens/Settings/ContentAndMediaSettings.tsx:76 -#: src/screens/Settings/ContentAndMediaSettings.tsx:79 +#: src/screens/Settings/ContentAndMediaSettings.tsx:77 +#: src/screens/Settings/ContentAndMediaSettings.tsx:80 msgid "Following feed preferences" msgstr "" @@ -4411,24 +4429,24 @@ msgstr "" msgid "Go Home" msgstr "" -#: src/view/com/profile/ProfileMenu.tsx:338 -#: src/view/com/profile/ProfileMenu.tsx:351 +#: src/view/com/profile/ProfileMenu.tsx:355 +#: src/view/com/profile/ProfileMenu.tsx:376 msgid "Go live" msgstr "" -#: src/components/live/GoLiveDialog.tsx:90 #: src/components/live/GoLiveDialog.tsx:95 -#: src/components/live/GoLiveDialog.tsx:229 -#: src/components/live/GoLiveDialog.tsx:238 +#: src/components/live/GoLiveDialog.tsx:100 +#: src/components/live/GoLiveDialog.tsx:228 +#: src/components/live/GoLiveDialog.tsx:237 msgid "Go Live" msgstr "" -#: src/view/com/profile/ProfileMenu.tsx:335 -#: src/view/com/profile/ProfileMenu.tsx:347 +#: src/view/com/profile/ProfileMenu.tsx:352 +#: src/view/com/profile/ProfileMenu.tsx:372 msgid "Go live (disabled)" msgstr "" -#: src/components/live/GoLiveDialog.tsx:171 +#: src/components/live/GoLiveDialog.tsx:170 msgid "Go live for" msgstr "" @@ -4511,6 +4529,11 @@ msgstr "" msgid "Handle too long. Please try a shorter one." msgstr "" +#: src/components/FeedCard.tsx:157 +#: src/features/liveEvents/components/LiveEventFeedCardWide.tsx:120 +msgid "Happening now" +msgstr "" + #: src/screens/Settings/AccessibilitySettings.tsx:85 msgid "Haptics" msgstr "" @@ -4558,8 +4581,8 @@ msgstr "" #: src/screens/Settings/Settings.tsx:254 #: src/screens/Settings/Settings.tsx:258 -#: src/view/shell/desktop/RightNav.tsx:125 #: src/view/shell/desktop/RightNav.tsx:128 +#: src/view/shell/desktop/RightNav.tsx:131 #: src/view/shell/Drawer.tsx:381 msgid "Help" msgstr "" @@ -4603,7 +4626,7 @@ msgstr "" #: src/components/moderation/ContentHider.tsx:208 #: src/components/moderation/LabelPreference.tsx:140 #: src/components/moderation/PostHider.tsx:137 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:747 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:773 #: src/lib/moderation/useLabelBehaviorDescription.ts:18 #: src/lib/moderation/useLabelBehaviorDescription.ts:23 #: src/lib/moderation/useLabelBehaviorDescription.ts:28 @@ -4617,6 +4640,11 @@ msgctxt "action" msgid "Hide" msgstr "" +#: src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx:140 +#: src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx:147 +msgid "Hide all events" +msgstr "" + #: src/components/dialogs/Embed.tsx:128 msgid "Hide customization options" msgstr "" @@ -4625,18 +4653,18 @@ msgstr "" msgid "Hide lists" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:578 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:584 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:604 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:610 msgid "Hide post for me" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:595 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:605 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:621 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:631 msgid "Hide reply for everyone" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:577 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:583 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:603 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:609 msgid "Hide reply for me" msgstr "" @@ -4644,12 +4672,17 @@ msgstr "" msgid "Hide this card" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:742 +#: src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx:128 +#: src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx:135 +msgid "Hide this event" +msgstr "" + +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:768 msgid "Hide this post?" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:742 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:777 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:768 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:803 msgid "Hide this reply?" msgstr "" @@ -4793,6 +4826,10 @@ msgstr "" msgid "If you believe your birthdate is incorrect, you can update it by <0>clicking here." msgstr "" +#: src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx:119 +msgid "If you choose to hide all events, you can always re-enable them from <0>Settings → Content & Media." +msgstr "" + #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:277 msgid "If you delete this list, you won't be able to recover it." msgstr "" @@ -4805,7 +4842,7 @@ msgstr "" msgid "If you need to update your email, <0>click here." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:733 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:759 msgid "If you remove this post, you won't be able to recover it." msgstr "" @@ -4967,7 +5004,7 @@ msgstr "" msgid "Invalid handle. Please try a different one." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:365 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:391 msgctxt "toast" msgid "Invalid interaction settings." msgstr "" @@ -5269,7 +5306,7 @@ msgid "Like" msgstr "" #. Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form -#: src/components/PostControls/index.tsx:270 +#: src/components/PostControls/index.tsx:286 msgid "Like ({0, plural, one {# like} other {# likes}})" msgstr "" @@ -5305,7 +5342,7 @@ msgstr "" msgid "Liked By" msgstr "" -#: src/components/FeedCard.tsx:218 +#: src/components/FeedCard.tsx:250 #: src/view/com/feeds/FeedSourceCard.tsx:172 msgid "Liked by {0, plural, one {# user} other {# users}}" msgstr "" @@ -5334,7 +5371,7 @@ msgstr "" msgid "Likes of your reposts notifications" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:470 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:482 msgid "Likes on this post" msgstr "" @@ -5445,23 +5482,36 @@ msgstr "" msgid "LIVE" msgstr "" +#: src/features/liveEvents/components/LiveEventFeedCardCompact.tsx:52 +#: src/features/liveEvents/components/LiveEventFeedCardWide.tsx:52 +msgid "Live event happening now: {0}" +msgstr "" + +#: src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx:107 +msgid "Live event options" +msgstr "" + +#: src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx:111 +msgid "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." +msgstr "" + #: src/components/live/LiveStatusDialog.tsx:235 msgid "Live feature is in beta" msgstr "" -#: src/components/live/EditLiveDialog.tsx:148 -#: src/components/live/EditLiveDialog.tsx:152 -#: src/components/live/GoLiveDialog.tsx:121 -#: src/components/live/GoLiveDialog.tsx:125 +#: src/components/live/EditLiveDialog.tsx:147 +#: src/components/live/EditLiveDialog.tsx:151 +#: src/components/live/GoLiveDialog.tsx:126 +#: src/components/live/GoLiveDialog.tsx:130 msgid "Live link" msgstr "" -#: src/screens/Search/Explore.tsx:83 +#: src/screens/Search/Explore.tsx:84 msgid "Load more" msgstr "" -#: src/screens/Search/Explore.tsx:501 -#: src/screens/Search/Explore.tsx:578 +#: src/screens/Search/Explore.tsx:506 +#: src/screens/Search/Explore.tsx:583 msgid "Load more suggested feeds" msgstr "" @@ -5500,11 +5550,11 @@ msgstr "" msgid "Logged-out visibility" msgstr "" -#: src/view/shell/desktop/RightNav.tsx:137 +#: src/view/shell/desktop/RightNav.tsx:140 msgid "Logo by @sawaratsuki.bsky.social" msgstr "" -#: src/view/shell/desktop/RightNav.tsx:134 +#: src/view/shell/desktop/RightNav.tsx:137 #: src/view/shell/Drawer.tsx:709 msgid "Logo by <0>@sawaratsuki.bsky.social" msgstr "" @@ -5541,8 +5591,8 @@ msgstr "" msgid "Make sure this is where you intend to go!" msgstr "" -#: src/screens/Settings/ContentAndMediaSettings.tsx:60 -#: src/screens/Settings/ContentAndMediaSettings.tsx:63 +#: src/screens/Settings/ContentAndMediaSettings.tsx:61 +#: src/screens/Settings/ContentAndMediaSettings.tsx:64 msgid "Manage saved feeds" msgstr "" @@ -5729,8 +5779,8 @@ msgid "More languages..." msgstr "" #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:149 -#: src/view/com/profile/ProfileMenu.tsx:229 -#: src/view/com/profile/ProfileMenu.tsx:235 +#: src/view/com/profile/ProfileMenu.tsx:241 +#: src/view/com/profile/ProfileMenu.tsx:248 msgid "More options" msgstr "" @@ -5761,10 +5811,10 @@ msgstr "" msgid "Mute {tag}" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:660 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:666 -#: src/view/com/profile/ProfileMenu.tsx:389 -#: src/view/com/profile/ProfileMenu.tsx:396 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:686 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:692 +#: src/view/com/profile/ProfileMenu.tsx:433 +#: src/view/com/profile/ProfileMenu.tsx:440 msgid "Mute account" msgstr "" @@ -5814,13 +5864,13 @@ msgstr "" msgid "Mute this word until you unmute it" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:544 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:548 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:570 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:574 msgid "Mute thread" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:558 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:560 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:584 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:586 msgid "Mute words & tags" msgstr "" @@ -5894,6 +5944,7 @@ msgid "Nevermind, create a handle for me" msgstr "" #: src/screens/Search/modules/ExploreTrendingTopics.tsx:196 +#: src/view/com/profile/ProfileMenu.tsx:388 msgid "New" msgstr "" @@ -6054,7 +6105,7 @@ msgstr "" msgid "No DNS Panel" msgstr "" -#: src/components/live/EditLiveDialog.tsx:140 +#: src/components/live/EditLiveDialog.tsx:139 msgid "No expiry set" msgstr "" @@ -6153,7 +6204,7 @@ msgstr "" msgid "No results" msgstr "" -#: src/screens/Search/Explore.tsx:793 +#: src/screens/Search/Explore.tsx:800 msgid "No results for \"{0}\"." msgstr "" @@ -6170,7 +6221,7 @@ msgstr "" msgid "No results found for \"<0>{query}\"." msgstr "" -#: src/screens/Search/Explore.tsx:797 +#: src/screens/Search/Explore.tsx:804 msgid "No results." msgstr "" @@ -6238,7 +6289,7 @@ msgstr "" msgid "Not Found" msgstr "" -#: src/view/com/profile/ProfileMenu.tsx:511 +#: src/view/com/profile/ProfileMenu.tsx:555 msgid "Note about sharing" msgstr "" @@ -6338,7 +6389,7 @@ msgid "OK" msgstr "" #: src/screens/Login/PasswordUpdatedForm.tsx:37 -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:693 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:705 msgid "Okay" msgstr "" @@ -6416,7 +6467,7 @@ msgstr "" msgid "Open avatar creator" msgstr "" -#: src/view/com/feeds/ComposerPrompt.tsx:200 +#: src/view/com/feeds/ComposerPrompt.tsx:198 msgid "Open camera" msgstr "" @@ -6467,7 +6518,7 @@ msgstr "" msgid "Open pack" msgstr "" -#: src/components/PostControls/PostMenu/index.tsx:64 +#: src/components/PostControls/PostMenu/index.tsx:66 msgid "Open post options menu" msgstr "" @@ -6476,7 +6527,7 @@ msgstr "" msgid "Open profile" msgstr "" -#: src/components/PostControls/ShareMenu/index.tsx:89 +#: src/components/PostControls/ShareMenu/index.tsx:107 msgid "Open share menu" msgstr "" @@ -6529,7 +6580,7 @@ msgstr "" msgid "Opens composer" msgstr "" -#: src/view/com/feeds/ComposerPrompt.tsx:201 +#: src/view/com/feeds/ComposerPrompt.tsx:199 msgid "Opens device camera" msgstr "" @@ -6560,7 +6611,7 @@ msgstr "" msgid "Opens helpdesk in browser" msgstr "" -#: src/view/com/feeds/ComposerPrompt.tsx:223 +#: src/view/com/feeds/ComposerPrompt.tsx:221 msgid "Opens image picker" msgstr "" @@ -6779,7 +6830,7 @@ msgstr "" msgid "Pin feed" msgstr "" -#: src/components/FeedCard.tsx:329 +#: src/components/FeedCard.tsx:361 msgid "Pin Feed" msgstr "" @@ -6792,8 +6843,8 @@ msgstr "" msgid "Pin to Home" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:452 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:459 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:478 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:485 msgid "Pin to your profile" msgstr "" @@ -7046,7 +7097,7 @@ msgstr "" msgid "Post by @{0}" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:184 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:186 msgctxt "toast" msgid "Post deleted" msgstr "" @@ -7096,7 +7147,7 @@ msgctxt "toast" msgid "Post pinned" msgstr "" -#: src/components/PostControls/BookmarkButton.tsx:59 +#: src/components/PostControls/BookmarkButton.tsx:66 msgid "Post saved" msgstr "" @@ -7153,6 +7204,10 @@ msgstr "" msgid "Press to view followers of this account that you also follow" msgstr "" +#: src/features/liveEvents/components/LiveEventFeedCardWide.tsx:118 +msgid "Preview" +msgstr "" + #: src/view/com/lightbox/Lightbox.web.tsx:151 msgid "Previous image" msgstr "" @@ -7165,8 +7220,8 @@ msgstr "" msgid "Primary Language" msgstr "" -#: src/view/shell/desktop/RightNav.tsx:113 -#: src/view/shell/desktop/RightNav.tsx:114 +#: src/view/shell/desktop/RightNav.tsx:116 +#: src/view/shell/desktop/RightNav.tsx:117 msgid "Privacy" msgstr "" @@ -7299,11 +7354,11 @@ msgstr "" msgid "Quote post" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:314 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:340 msgid "Quote post was re-attached" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:313 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:339 msgid "Quote post was successfully detached" msgstr "" @@ -7321,7 +7376,7 @@ msgstr "" msgid "Quotes" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:454 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:466 msgid "Quotes of this post" msgstr "" @@ -7333,8 +7388,8 @@ msgstr "" msgid "Rate limit exceeded. Please try again later." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:620 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:630 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:646 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:656 msgid "Re-attach quote" msgstr "" @@ -7445,7 +7500,7 @@ msgstr "" #: src/components/dialogs/MutedWords.tsx:443 #: src/components/dialogs/StarterPackDialog.tsx:374 #: src/components/dialogs/StarterPackDialog.tsx:380 -#: src/components/FeedCard.tsx:343 +#: src/components/FeedCard.tsx:375 #: src/components/StarterPack/Wizard/WizardListCard.tsx:105 #: src/components/StarterPack/Wizard/WizardListCard.tsx:112 #: src/screens/Bookmarks/index.tsx:266 @@ -7518,12 +7573,12 @@ msgstr "" msgid "Remove from saved feeds" msgstr "" -#: src/components/PostControls/BookmarkButton.tsx:128 +#: src/components/PostControls/BookmarkButton.tsx:140 #: src/screens/Bookmarks/index.tsx:260 msgid "Remove from saved posts" msgstr "" -#: src/components/FeedCard.tsx:338 +#: src/components/FeedCard.tsx:370 msgid "Remove from your feeds?" msgstr "" @@ -7531,8 +7586,8 @@ msgstr "" msgid "Remove image" msgstr "" -#: src/components/live/EditLiveDialog.tsx:240 -#: src/components/live/EditLiveDialog.tsx:247 +#: src/components/live/EditLiveDialog.tsx:226 +#: src/components/live/EditLiveDialog.tsx:233 msgid "Remove live status" msgstr "" @@ -7568,8 +7623,8 @@ msgstr "" #: src/components/verification/VerificationRemovePrompt.tsx:46 #: src/components/verification/VerificationsDialog.tsx:252 -#: src/view/com/profile/ProfileMenu.tsx:362 -#: src/view/com/profile/ProfileMenu.tsx:365 +#: src/view/com/profile/ProfileMenu.tsx:406 +#: src/view/com/profile/ProfileMenu.tsx:409 msgid "Remove verification" msgstr "" @@ -7594,7 +7649,7 @@ msgstr "" msgid "Removed from saved feeds" msgstr "" -#: src/components/PostControls/BookmarkButton.tsx:94 +#: src/components/PostControls/BookmarkButton.tsx:106 #: src/screens/Bookmarks/index.tsx:218 msgid "Removed from saved posts" msgstr "" @@ -7662,7 +7717,7 @@ msgid "Reply" msgstr "" #. Accessibility label for the reply button, verb form followed by number of replies and noun form -#: src/components/PostControls/index.tsx:224 +#: src/components/PostControls/index.tsx:240 msgid "Reply ({0, plural, one {# reply} other {# replies}})" msgstr "" @@ -7688,12 +7743,12 @@ msgstr "" msgid "Reply sorting" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:351 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:377 msgctxt "toast" msgid "Reply visibility updated" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:350 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:376 msgid "Reply was successfully hidden" msgstr "" @@ -7704,8 +7759,8 @@ msgstr "" msgid "Report" msgstr "" -#: src/view/com/profile/ProfileMenu.tsx:429 -#: src/view/com/profile/ProfileMenu.tsx:432 +#: src/view/com/profile/ProfileMenu.tsx:473 +#: src/view/com/profile/ProfileMenu.tsx:476 msgid "Report account" msgstr "" @@ -7736,8 +7791,8 @@ msgstr "" msgid "Report message" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:686 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:688 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:712 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:714 msgid "Report post" msgstr "" @@ -7828,7 +7883,7 @@ msgstr "" msgid "Reposts" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:436 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:448 msgid "Reposts of this post" msgstr "" @@ -7983,8 +8038,8 @@ msgstr "" #: src/components/dialogs/lists/CreateOrEditListDialog.tsx:307 #: src/components/dialogs/PostInteractionSettingsDialog.tsx:662 #: src/components/dialogs/PostInteractionSettingsDialog.tsx:667 -#: src/components/live/EditLiveDialog.tsx:216 -#: src/components/live/EditLiveDialog.tsx:223 +#: src/components/live/EditLiveDialog.tsx:202 +#: src/components/live/EditLiveDialog.tsx:209 #: src/components/StarterPack/QrCodeDialog.tsx:204 #: src/screens/Profile/Header/EditProfileDialog.tsx:236 #: src/screens/Profile/Header/EditProfileDialog.tsx:250 @@ -8134,11 +8189,11 @@ msgstr "" msgid "Search for feeds that you want to suggest to others." msgstr "" -#: src/screens/Search/Explore.tsx:357 +#: src/screens/Search/Explore.tsx:362 msgid "Search for more accounts" msgstr "" -#: src/screens/Search/Explore.tsx:434 +#: src/screens/Search/Explore.tsx:439 msgid "Search for more feeds" msgstr "" @@ -8164,8 +8219,8 @@ msgstr "" msgid "Search my posts" msgstr "" -#: src/view/com/profile/ProfileMenu.tsx:272 -#: src/view/com/profile/ProfileMenu.tsx:275 +#: src/view/com/profile/ProfileMenu.tsx:289 +#: src/view/com/profile/ProfileMenu.tsx:292 msgid "Search posts" msgstr "" @@ -8283,7 +8338,7 @@ msgstr "" msgid "Select content languages" msgstr "" -#: src/components/live/GoLiveDialog.tsx:176 +#: src/components/live/GoLiveDialog.tsx:175 msgid "Select duration" msgstr "" @@ -8575,7 +8630,7 @@ msgstr "" msgid "Share a fun fact!" msgstr "" -#: src/view/com/profile/ProfileMenu.tsx:516 +#: src/view/com/profile/ProfileMenu.tsx:560 msgid "Share anyway" msgstr "" @@ -8623,8 +8678,8 @@ msgstr "" #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:171 #: src/screens/StarterPack/StarterPackScreen.tsx:613 #: src/screens/StarterPack/StarterPackScreen.tsx:621 -#: src/view/com/profile/ProfileMenu.tsx:252 -#: src/view/com/profile/ProfileMenu.tsx:265 +#: src/view/com/profile/ProfileMenu.tsx:269 +#: src/view/com/profile/ProfileMenu.tsx:282 msgid "Share via..." msgstr "" @@ -8671,8 +8726,8 @@ msgstr "" msgid "Show customization options" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:515 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:517 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:541 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:543 msgid "Show less like this" msgstr "" @@ -8684,12 +8739,17 @@ msgstr "" msgid "Show lists of users to select from" msgstr "" +#: src/features/liveEvents/components/LiveEventFeedsSettingsToggle.tsx:27 +#: src/features/liveEvents/components/LiveEventFeedsSettingsToggle.tsx:37 +msgid "Show live events in your Discover Feed" +msgstr "" + #: src/components/Post/ShowMoreTextButton.tsx:51 msgid "Show More" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:507 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:509 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:533 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:535 msgid "Show more like this" msgstr "" @@ -8715,8 +8775,8 @@ msgstr "" msgid "Show replies as" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:594 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:604 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:620 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:630 msgid "Show reply for everyone" msgstr "" @@ -8738,7 +8798,12 @@ msgstr "" msgid "Show warning and filter from feeds" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:629 +#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:60 +#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:170 +msgid "Show when you’re live" +msgstr "" + +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:641 msgid "Shows information about when this post was created" msgstr "" @@ -8798,8 +8863,8 @@ msgstr "" msgid "Sign in to Bluesky or create a new account" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:493 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:495 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:519 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:521 msgid "Sign in to view post" msgstr "" @@ -8885,7 +8950,7 @@ msgstr "" msgid "Some of your verifications are invalid." msgstr "" -#: src/components/FeedInterstitials.tsx:870 +#: src/components/FeedInterstitials.tsx:871 msgid "Some other feeds you might like" msgstr "" @@ -8942,8 +9007,8 @@ msgstr "" msgid "Sorry, we're unable to load account suggestions at this time." msgstr "" -#: src/App.native.tsx:126 -#: src/App.web.tsx:99 +#: src/App.native.tsx:131 +#: src/App.web.tsx:104 msgid "Sorry! Your session expired. Please sign in again." msgstr "" @@ -9022,7 +9087,7 @@ msgstr "" msgid "Starter pack is invalid" msgstr "" -#: src/screens/Search/Explore.tsx:625 +#: src/screens/Search/Explore.tsx:630 #: src/view/screens/Profile.tsx:240 msgid "Starter Packs" msgstr "" @@ -9058,6 +9123,10 @@ msgstr "" msgid "Storybook" msgstr "" +#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:181 +msgid "Streaming on Twitch? Set your live status on Bluesky to add a badge to your avatar. Tapping it takes people straight to your stream." +msgstr "" + #: src/components/ageAssurance/AgeAssuranceAppealDialog.tsx:117 #: src/components/ageAssurance/AgeAssuranceAppealDialog.tsx:123 #: src/components/live/GoLiveDisabledDialog.tsx:138 @@ -9121,7 +9190,7 @@ msgstr "" msgid "Successfully verified" msgstr "" -#: src/screens/Search/Explore.tsx:354 +#: src/screens/Search/Explore.tsx:359 msgid "Suggested Accounts" msgstr "" @@ -9256,8 +9325,8 @@ msgstr "" msgid "Tell us a little more" msgstr "" -#: src/view/shell/desktop/RightNav.tsx:120 -#: src/view/shell/desktop/RightNav.tsx:121 +#: src/view/shell/desktop/RightNav.tsx:123 +#: src/view/shell/desktop/RightNav.tsx:124 msgid "Terms" msgstr "" @@ -9323,7 +9392,7 @@ msgstr "" #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:186 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:391 -#: src/view/com/profile/ProfileMenu.tsx:492 +#: src/view/com/profile/ProfileMenu.tsx:536 msgid "The account will be able to interact with you after unblocking." msgstr "" @@ -9377,6 +9446,10 @@ msgstr "" msgid "The following labels were applied to your content." msgstr "" +#: src/components/live/GoLiveDialog.tsx:157 +msgid "The following services are enabled for your account: {allowedServices}" +msgstr "" + #: src/screens/ModerationInteractionSettings/index.tsx:42 msgid "The following settings will be used as your defaults when creating new posts. You can edit these for a specific post from the composer." msgstr "" @@ -9465,7 +9538,7 @@ msgstr "" msgid "There was an issue fetching notifications. Tap here to try again." msgstr "" -#: src/screens/Search/Explore.tsx:992 +#: src/screens/Search/Explore.tsx:999 #: src/view/com/posts/PostFeed.tsx:759 msgid "There was an issue fetching posts. Tap here to try again." msgstr "" @@ -9497,21 +9570,21 @@ msgstr "" msgid "There was an issue updating your feeds, please check your internet connection and try again." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:397 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:410 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:420 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:423 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:436 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:446 #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:90 #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:101 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:88 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:253 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:279 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:294 -#: src/view/com/profile/ProfileMenu.tsx:139 -#: src/view/com/profile/ProfileMenu.tsx:149 -#: src/view/com/profile/ProfileMenu.tsx:163 -#: src/view/com/profile/ProfileMenu.tsx:173 -#: src/view/com/profile/ProfileMenu.tsx:186 +#: src/view/com/profile/ProfileMenu.tsx:151 +#: src/view/com/profile/ProfileMenu.tsx:161 +#: src/view/com/profile/ProfileMenu.tsx:175 +#: src/view/com/profile/ProfileMenu.tsx:185 #: src/view/com/profile/ProfileMenu.tsx:198 +#: src/view/com/profile/ProfileMenu.tsx:210 msgid "There was an issue! {0}" msgstr "" @@ -9671,8 +9744,8 @@ msgstr "" msgid "This is an empty state" msgstr "" -#: src/components/live/EditLiveDialog.tsx:189 -#: src/components/live/GoLiveDialog.tsx:157 +#: src/components/live/EditLiveDialog.tsx:176 +#: src/components/live/GoLiveDialog.tsx:150 msgid "This is not a valid link" msgstr "" @@ -9708,7 +9781,7 @@ msgstr "" msgid "This moderation service is unavailable. See below for more details. If this issue persists, contact us." msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:669 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:681 msgid "This post claims to have been created on <0>{0}, but was first seen by Bluesky on <1>{1}." msgstr "" @@ -9724,7 +9797,7 @@ msgstr "" msgid "This post was deleted by its author" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:744 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:770 msgid "This post will be hidden from feeds and threads. This cannot be undone." msgstr "" @@ -9732,11 +9805,11 @@ msgstr "" msgid "This post's author has disabled quote posts." msgstr "" -#: src/view/com/profile/ProfileMenu.tsx:513 +#: src/view/com/profile/ProfileMenu.tsx:557 msgid "This profile is only visible to logged-in users. It won't be visible to people who aren't signed in." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:779 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:805 msgid "This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others." msgstr "" @@ -9744,6 +9817,10 @@ msgstr "" msgid "This service has not provided terms of service or a privacy policy." msgstr "" +#: src/components/live/queries.ts:38 +msgid "This service is not supported while the Live feature is in beta. Allowed services: {formatted}." +msgstr "" + #: src/screens/Settings/components/ChangeHandleDialog.tsx:467 msgid "This should create a domain record at:" msgstr "" @@ -9797,7 +9874,7 @@ msgstr "" msgid "This will remove @{0} from the quick access list." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:769 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:795 msgid "This will remove your post from this quote post for all users, and replace it with a placeholder." msgstr "" @@ -9806,8 +9883,8 @@ msgstr "" msgid "Thread options" msgstr "" -#: src/screens/Settings/ContentAndMediaSettings.tsx:68 -#: src/screens/Settings/ContentAndMediaSettings.tsx:71 +#: src/screens/Settings/ContentAndMediaSettings.tsx:69 +#: src/screens/Settings/ContentAndMediaSettings.tsx:72 msgid "Thread preferences" msgstr "" @@ -9892,10 +9969,10 @@ msgstr "" #: src/components/dms/MessageContextMenu.tsx:139 #: src/components/dms/MessageContextMenu.tsx:141 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:476 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:478 -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:591 -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:594 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:502 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:504 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:603 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:606 msgid "Translate" msgstr "" @@ -10001,7 +10078,7 @@ msgstr "" #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:394 #: src/screens/ProfileList/components/Header.tsx:171 #: src/screens/ProfileList/components/Header.tsx:178 -#: src/view/com/profile/ProfileMenu.tsx:504 +#: src/view/com/profile/ProfileMenu.tsx:548 msgid "Unblock" msgstr "" @@ -10012,14 +10089,14 @@ msgstr "" #: src/components/dms/ConvoMenu.tsx:261 #: src/components/dms/ConvoMenu.tsx:264 -#: src/view/com/profile/ProfileMenu.tsx:409 -#: src/view/com/profile/ProfileMenu.tsx:415 +#: src/view/com/profile/ProfileMenu.tsx:453 +#: src/view/com/profile/ProfileMenu.tsx:459 msgid "Unblock account" msgstr "" #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:184 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:389 -#: src/view/com/profile/ProfileMenu.tsx:486 +#: src/view/com/profile/ProfileMenu.tsx:530 msgid "Unblock Account?" msgstr "" @@ -10028,7 +10105,12 @@ msgstr "" msgid "Unblock list" msgstr "" -#: src/components/PostControls/BookmarkButton.tsx:40 +#: src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx:72 +#: src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx:78 +msgid "Undo" +msgstr "" + +#: src/components/PostControls/BookmarkButton.tsx:42 msgctxt "Button label to undo saving/removing a post from saved posts." msgid "Undo" msgstr "" @@ -10047,8 +10129,8 @@ msgstr "" msgid "Unfollow {0}" msgstr "" -#: src/view/com/profile/ProfileMenu.tsx:292 -#: src/view/com/profile/ProfileMenu.tsx:302 +#: src/view/com/profile/ProfileMenu.tsx:309 +#: src/view/com/profile/ProfileMenu.tsx:319 msgid "Unfollow account" msgstr "" @@ -10085,7 +10167,7 @@ msgid "Unlike" msgstr "" #. Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun -#: src/components/PostControls/index.tsx:260 +#: src/components/PostControls/index.tsx:276 msgid "Unlike ({0, plural, one {# like} other {# likes}})" msgstr "" @@ -10105,10 +10187,10 @@ msgstr "" msgid "Unmute {tag}" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:659 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:665 -#: src/view/com/profile/ProfileMenu.tsx:388 -#: src/view/com/profile/ProfileMenu.tsx:394 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:685 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:691 +#: src/view/com/profile/ProfileMenu.tsx:432 +#: src/view/com/profile/ProfileMenu.tsx:438 msgid "Unmute account" msgstr "" @@ -10121,8 +10203,8 @@ msgstr "" msgid "Unmute list" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:544 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:548 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:570 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:574 msgid "Unmute thread" msgstr "" @@ -10141,7 +10223,7 @@ msgstr "" msgid "Unpin feed" msgstr "" -#: src/components/FeedCard.tsx:320 +#: src/components/FeedCard.tsx:352 msgid "Unpin Feed" msgstr "" @@ -10150,8 +10232,8 @@ msgstr "" msgid "Unpin from home" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:451 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:458 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:477 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:484 msgid "Unpin from profile" msgstr "" @@ -10223,12 +10305,12 @@ msgstr "" msgid "Update your email" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:318 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:344 msgctxt "toast" msgid "Updating quote attachment failed" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:370 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:396 msgctxt "toast" msgid "Updating reply visibility failed" msgstr "" @@ -10290,8 +10372,8 @@ msgstr "" msgid "Use in-app browser" msgstr "" -#: src/screens/Settings/ContentAndMediaSettings.tsx:102 -#: src/screens/Settings/ContentAndMediaSettings.tsx:108 +#: src/screens/Settings/ContentAndMediaSettings.tsx:103 +#: src/screens/Settings/ContentAndMediaSettings.tsx:109 msgid "Use in-app browser to open links" msgstr "" @@ -10416,8 +10498,8 @@ msgstr "" #: src/components/verification/VerificationCreatePrompt.tsx:84 #: src/components/verification/VerificationCreatePrompt.tsx:86 -#: src/view/com/profile/ProfileMenu.tsx:372 -#: src/view/com/profile/ProfileMenu.tsx:375 +#: src/view/com/profile/ProfileMenu.tsx:416 +#: src/view/com/profile/ProfileMenu.tsx:419 msgid "Verify account" msgstr "" @@ -10922,7 +11004,7 @@ msgstr "" #: src/view/com/auth/SplashScreen.tsx:51 #: src/view/com/auth/SplashScreen.web.tsx:103 #: src/view/com/composer/Composer.tsx:857 -#: src/view/com/feeds/ComposerPrompt.tsx:191 +#: src/view/com/feeds/ComposerPrompt.tsx:189 msgid "What's up?" msgstr "" @@ -11025,8 +11107,8 @@ msgstr "" msgid "Wrong DID returned from server. Received: {0}" msgstr "" -#: src/components/live/EditLiveDialog.tsx:153 -#: src/components/live/GoLiveDialog.tsx:126 +#: src/components/live/EditLiveDialog.tsx:152 +#: src/components/live/GoLiveDialog.tsx:131 msgid "www.mylivestream.tv" msgstr "" @@ -11043,11 +11125,11 @@ msgstr "" msgid "Yes, delete this starter pack" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:772 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:798 msgid "Yes, detach" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:782 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:808 msgid "Yes, hide" msgstr "" @@ -11084,19 +11166,15 @@ msgstr "" msgid "You are in line." msgstr "" -#: src/components/live/EditLiveDialog.tsx:119 -#: src/components/live/EditLiveDialog.tsx:124 +#: src/components/live/EditLiveDialog.tsx:118 +#: src/components/live/EditLiveDialog.tsx:123 msgid "You are Live" msgstr "" -#: src/components/live/queries.ts:211 +#: src/components/live/queries.ts:212 msgid "You are no longer live" msgstr "" -#: src/components/live/queries.ts:35 -msgid "You are not allowed to go live" -msgstr "" - #: src/view/com/composer/state/video.ts:411 msgid "You are not allowed to upload videos." msgstr "" @@ -11105,7 +11183,7 @@ msgstr "" msgid "You are not following anyone yet" msgstr "" -#: src/components/live/queries.ts:156 +#: src/components/live/queries.ts:157 msgid "You are now live!" msgstr "" @@ -11146,7 +11224,7 @@ msgstr "" msgid "You can continue ongoing conversations regardless of which setting you choose." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:357 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:383 msgctxt "toast" msgid "You can hide a maximum of {MAX_HIDDEN_REPLIES} replies." msgstr "" @@ -11392,11 +11470,11 @@ msgstr "" msgid "You will no longer receive notifications for {0}" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:217 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:231 msgid "You will no longer receive notifications for this thread" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:213 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:221 msgid "You will now receive notifications for this thread" msgstr "" @@ -11597,8 +11675,8 @@ msgstr "" #: src/Navigation.tsx:526 #: src/screens/Search/modules/ExploreInterestsCard.tsx:67 -#: src/screens/Settings/ContentAndMediaSettings.tsx:92 -#: src/screens/Settings/ContentAndMediaSettings.tsx:95 +#: src/screens/Settings/ContentAndMediaSettings.tsx:93 +#: src/screens/Settings/ContentAndMediaSettings.tsx:96 #: src/screens/Settings/InterestsSettings.tsx:46 msgid "Your interests" msgstr "" @@ -11612,6 +11690,10 @@ msgstr "" msgid "Your interests help us find what you like!" msgstr "" +#: src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx:68 +msgid "Your live event preferences have been updated." +msgstr "" + #: src/components/dialogs/DeviceLocationRequestDialog.tsx:123 msgid "Your location data is not tracked and does not leave your device." msgstr "" diff --git a/src/logger/index.ts b/src/logger/index.ts index 998d025818..57d430b5dd 100644 --- a/src/logger/index.ts +++ b/src/logger/index.ts @@ -16,6 +16,8 @@ import {enabledLogLevels} from '#/logger/util' import {isNative} from '#/platform/detection' import {ENV} from '#/env' +export {type MetricEvents as Metrics} from '#/logger/metrics' + const TRANSPORTS: Transport[] = (function configureTransports() { switch (ENV) { case 'production': { diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index 949c883b7c..7afe7f3bbe 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -1,5 +1,6 @@ import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' import {type FeedDescriptor} from '#/state/queries/post-feed' +import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' export type MetricEvents = { // App events @@ -176,13 +177,19 @@ export type MetricEvents = { 'feed:suggestion:press': { feedUrl: string } - 'feed:showMore': { - feed: string - feedContext: string + 'post:showMore': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number } - 'feed:showLess': { - feed: string - feedContext: string + 'post:showLess': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number } 'feed:clickthrough': { feed: string @@ -238,6 +245,8 @@ export type MetricEvents = { isReply: boolean } 'post:like': { + uri: string + authorDid: string doesLikerFollowPoster: boolean | undefined doesPosterFollowLiker: boolean | undefined likerClout: number | undefined @@ -246,26 +255,87 @@ export type MetricEvents = { feedDescriptor?: string } 'post:repost': { + uri: string + authorDid: string logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' feedDescriptor?: string } 'post:unlike': { + uri: string + authorDid: string logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' feedDescriptor?: string } 'post:unrepost': { + uri: string + authorDid: string logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' feedDescriptor?: string } - 'post:mute': {} - 'post:unmute': {} + 'post:mute': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number + } + 'post:unmute': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number + } 'post:pin': {} 'post:unpin': {} 'post:bookmark': { + uri: string + authorDid: string logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number } 'post:unbookmark': { + uri: string + authorDid: string logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number + } + 'post:clickReply': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number + } + 'post:clickQuotePost': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number + } + 'post:clickthroughAuthor': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number + } + 'post:clickthroughItem': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number + } + 'post:clickthroughEmbed': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number } 'post:view': { uri: string @@ -565,7 +635,14 @@ export type MetricEvents = { 'live:view:profile': {subject: string} 'live:view:post': {subject: string; feed?: string} - 'share:open': {context: 'feed' | 'thread'} + 'post:share': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + postContext: 'feed' | 'thread' + position?: number + } 'share:press:copyLink': {} 'share:press:nativeShare': {} 'share:press:openDmSearch': {} @@ -720,4 +797,27 @@ export type MetricEvents = { } // user pressed the remove all data button 'contacts:settings:removeData': {} + + 'liveEvents:feedBanner:seen': { + feed: string + context: LiveEventFeedMetricContext + } + 'liveEvents:feedBanner:click': { + feed: string + context: LiveEventFeedMetricContext + } + 'liveEvents:feedBanner:hide': { + feed: string + context: LiveEventFeedMetricContext + } + 'liveEvents:feedBanner:unhide': { + feed: string + context: LiveEventFeedMetricContext + } + 'liveEvents:hideAllFeedBanners': { + context: LiveEventFeedMetricContext + } + 'liveEvents:unhideAllFeedBanners': { + context: LiveEventFeedMetricContext + } } diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index 7785afe2ca..fbed7965b1 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -281,6 +281,12 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ ]) const onOpenAuthor = () => { + logger.metric('post:clickthroughAuthor', { + uri: post.uri, + authorDid: post.author.did, + logContext: 'PostThreadItem', + feedDescriptor: feedFeedback.feedDescriptor, + }) if (postSource) { feedFeedback.sendInteraction({ item: post.uri, @@ -292,6 +298,12 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ } const onOpenEmbed = () => { + logger.metric('post:clickthroughEmbed', { + uri: post.uri, + authorDid: post.author.did, + logContext: 'PostThreadItem', + feedDescriptor: feedFeedback.feedDescriptor, + }) if (postSource) { feedFeedback.sendInteraction({ item: post.uri, diff --git a/src/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx index b3e26dcfd3..6b49588db9 100644 --- a/src/screens/Search/Explore.tsx +++ b/src/screens/Search/Explore.tsx @@ -68,6 +68,7 @@ import {Loader} from '#/components/Loader' import * as ProfileCard from '#/components/ProfileCard' import {SubtleHover} from '#/components/SubtleHover' import {Text} from '#/components/Typography' +import {ExploreScreenLiveEventFeedsBanner} from '#/features/liveEvents/components/ExploreScreenLiveEventFeedsBanner' import * as ModuleHeader from './components/ModuleHeader' import { SuggestedAccountsTabBar, @@ -201,6 +202,10 @@ type ExploreScreenItems = type: 'interests-card' key: 'interests-card' } + | { + type: 'liveEventFeedsBanner' + key: string + } export function Explore({ focusSearchInput, @@ -684,6 +689,8 @@ export function Explore({ i.push(topBorder) i.push(...interestsNuxModule) + i.push({type: 'liveEventFeedsBanner', key: 'liveEventFeedsBanner'}) + if (useFullExperience) { i.push(trendingTopicsModule) i.push(...suggestedFeedsModule) @@ -998,6 +1005,9 @@ export function Explore({ case 'interests-card': { return } + case 'liveEventFeedsBanner': { + return + } } }, [ diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx index e1144d0645..ebcf95a7af 100644 --- a/src/screens/Settings/ContentAndMediaSettings.tsx +++ b/src/screens/Settings/ContentAndMediaSettings.tsx @@ -26,6 +26,7 @@ import {Play_Stroke2_Corner2_Rounded as PlayIcon} from '#/components/icons/Play' import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' import * as Layout from '#/components/Layout' +import {LiveEventFeedsSettingsToggle} from '#/features/liveEvents/components/LiveEventFeedsSettingsToggle' type Props = NativeStackScreenProps< CommonNavigatorParams, @@ -124,7 +125,7 @@ export function ContentAndMediaSettingsScreen({}: Props) { - {trendingEnabled && ( + {trendingEnabled ? ( <> + + ) : ( + <> + + + )} diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx index 1d719978a5..28f13fb5f2 100644 --- a/src/state/feed-feedback.tsx +++ b/src/state/feed-feedback.tsx @@ -137,7 +137,6 @@ export function useFeedFeedback( sendOrAggregateInteractionsForStats( aggregatedStats.current, interactionsToSend, - feed?.feedDescriptor ?? 'unknown', ) throttledFlushAggregatedStats() logger.debug('flushed') @@ -274,28 +273,10 @@ function createAggregatedStats(): AggregatedStats { function sendOrAggregateInteractionsForStats( stats: AggregatedStats, interactions: AppBskyFeedDefs.Interaction[], - feed: string, ) { for (let interaction of interactions) { switch (interaction.event) { - // Pressing "Show more" / "Show less" is relatively uncommon so we won't aggregate them. - // This lets us send the feed context together with them. - case 'app.bsky.feed.defs#requestLess': { - logger.metric('feed:showLess', { - feed, - feedContext: interaction.feedContext ?? '', - }) - break - } - case 'app.bsky.feed.defs#requestMore': { - logger.metric('feed:showMore', { - feed, - feedContext: interaction.feedContext ?? '', - }) - break - } - - // The rest of the events are aggregated and sent later in batches. + // The events are aggregated and sent later in batches. case 'app.bsky.feed.defs#clickthroughAuthor': case 'app.bsky.feed.defs#clickthroughEmbed': case 'app.bsky.feed.defs#clickthroughItem': diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts index 6c03c936a6..e23050c465 100644 --- a/src/state/queries/nuxs/definitions.ts +++ b/src/state/queries/nuxs/definitions.ts @@ -12,6 +12,8 @@ export enum Nux { BookmarksAnnouncement = 'BookmarksAnnouncement', FindContactsAnnouncement = 'FindContactsAnnouncement', FindContactsDismissibleBanner = 'FindContactsDismissibleBanner', + LiveNowBetaDialog = 'LiveNowBetaDialog', + LiveNowBetaNudge = 'LiveNowBetaNudge', /* * Blocking announcements. New IDs are required for each new announcement. @@ -62,6 +64,14 @@ export type AppNux = BaseNux< id: Nux.FindContactsDismissibleBanner data: undefined } + | { + id: Nux.LiveNowBetaDialog + data: undefined + } + | { + id: Nux.LiveNowBetaNudge + data: undefined + } > export const NuxSchemas: Record | undefined> = { @@ -75,4 +85,6 @@ export const NuxSchemas: Record | undefined> = { [Nux.BookmarksAnnouncement]: undefined, [Nux.FindContactsAnnouncement]: undefined, [Nux.FindContactsDismissibleBanner]: undefined, + [Nux.LiveNowBetaDialog]: undefined, + [Nux.LiveNowBetaNudge]: undefined, } diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts index 09ced9874e..3043485b3e 100644 --- a/src/state/queries/post.ts +++ b/src/state/queries/post.ts @@ -111,7 +111,7 @@ export function usePostLikeMutationQueue( const postCid = post.cid const initialLikeUri = post.viewer?.like const likeMutation = usePostLikeMutation(feedDescriptor, logContext, post) - const unlikeMutation = usePostUnlikeMutation(feedDescriptor, logContext) + const unlikeMutation = usePostUnlikeMutation(feedDescriptor, logContext, post) const queueToggle = useToggleMutationQueue({ initialState: initialLikeUri, @@ -182,6 +182,8 @@ function usePostLikeMutation( ownProfile = findProfileQueryData(queryClient, currentAccount.did) } logger.metric('post:like', { + uri, + authorDid: postAuthor.did, logContext, doesPosterFollowLiker: postAuthor.viewer ? Boolean(postAuthor.viewer.followedBy) @@ -206,11 +208,17 @@ function usePostLikeMutation( function usePostUnlikeMutation( feedDescriptor: string | undefined, logContext: LogEvents['post:unlike']['logContext'], + post: Shadow, ) { const agent = useAgent() return useMutation({ - mutationFn: ({likeUri}) => { - logger.metric('post:unlike', {logContext, feedDescriptor}) + mutationFn: ({postUri, likeUri}) => { + logger.metric('post:unlike', { + uri: postUri, + authorDid: post.author.did, + logContext, + feedDescriptor, + }) return agent.deleteLike(likeUri) }, }) @@ -227,8 +235,12 @@ export function usePostRepostMutationQueue( const postUri = post.uri const postCid = post.cid const initialRepostUri = post.viewer?.repost - const repostMutation = usePostRepostMutation(feedDescriptor, logContext) - const unrepostMutation = usePostUnrepostMutation(feedDescriptor, logContext) + const repostMutation = usePostRepostMutation(feedDescriptor, logContext, post) + const unrepostMutation = usePostUnrepostMutation( + feedDescriptor, + logContext, + post, + ) const queueToggle = useToggleMutationQueue({ initialState: initialRepostUri, @@ -280,6 +292,7 @@ export function usePostRepostMutationQueue( function usePostRepostMutation( feedDescriptor: string | undefined, logContext: LogEvents['post:repost']['logContext'], + post: Shadow, ) { const agent = useAgent() return useMutation< @@ -288,7 +301,12 @@ function usePostRepostMutation( {uri: string; cid: string; via?: {uri: string; cid: string}} // the post's uri and cid, and the repost uri/cid if present >({ mutationFn: ({uri, cid, via}) => { - logger.metric('post:repost', {logContext, feedDescriptor}) + logger.metric('post:repost', { + uri, + authorDid: post.author.did, + logContext, + feedDescriptor, + }) return agent.repost(uri, cid, via) }, }) @@ -297,11 +315,17 @@ function usePostRepostMutation( function usePostUnrepostMutation( feedDescriptor: string | undefined, logContext: LogEvents['post:unrepost']['logContext'], + post: Shadow, ) { const agent = useAgent() return useMutation({ - mutationFn: ({repostUri}) => { - logger.metric('post:unrepost', {logContext, feedDescriptor}) + mutationFn: ({postUri, repostUri}) => { + logger.metric('post:unrepost', { + uri: postUri, + authorDid: post.author.did, + logContext, + feedDescriptor, + }) return agent.deleteRepost(repostUri) }, }) @@ -373,7 +397,6 @@ function useThreadMuteMutation() { {uri: string} // the root post's uri >({ mutationFn: ({uri}) => { - logger.metric('post:mute', {}) return agent.api.app.bsky.graph.muteThread({root: uri}) }, }) @@ -383,7 +406,6 @@ function useThreadUnmuteMutation() { const agent = useAgent() return useMutation<{}, Error, {uri: string}>({ mutationFn: ({uri}) => { - logger.metric('post:unmute', {}) return agent.api.app.bsky.graph.unmuteThread({root: uri}) }, }) diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts index 54d063a502..7508fa4f6d 100644 --- a/src/state/queries/preferences/const.ts +++ b/src/state/queries/preferences/const.ts @@ -45,4 +45,8 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = { verificationPrefs: { hideBadges: false, }, + liveEventPreferences: { + hideAllFeeds: false, + hiddenFeedIds: [], + }, } diff --git a/src/state/service-config.tsx b/src/state/service-config.tsx index 242022b60d..a04cbb51ee 100644 --- a/src/state/service-config.tsx +++ b/src/state/service-config.tsx @@ -1,7 +1,10 @@ import {createContext, useContext, useMemo} from 'react' +import {useGate} from '#/lib/statsig/statsig' import {useLanguagePrefs} from '#/state/preferences/languages' import {useServiceConfigQuery} from '#/state/queries/service-config' +import {useSession} from '#/state/session' +import {IS_DEV} from '#/env' import {device} from '#/storage' type TrendingContext = { @@ -18,7 +21,7 @@ const TrendingContext = createContext({ }) TrendingContext.displayName = 'TrendingContext' -const LiveNowContext = createContext(null) +const LiveNowContext = createContext([]) LiveNowContext.displayName = 'LiveNowContext' const CheckEmailConfirmedContext = createContext(null) @@ -82,19 +85,28 @@ export function useTrendingConfig() { return useContext(TrendingContext) } -export function useLiveNowConfig() { +const DEFAULT_LIVE_ALLOWED_DOMAINS = ['twitch.tv', 'www.twitch.tv'] +export type LiveNowConfig = { + allowedDomains: Set +} +export function useLiveNowConfig(): LiveNowConfig { const ctx = useContext(LiveNowContext) - if (!ctx) { - throw new Error( - 'useLiveNowConfig must be used within a ServiceConfigManager', - ) + const canGoLive = useCanGoLive() + const {currentAccount} = useSession() + if (!currentAccount?.did || !canGoLive) return {allowedDomains: new Set()} + const vip = ctx.find(live => live.did === currentAccount.did) + return { + allowedDomains: new Set( + DEFAULT_LIVE_ALLOWED_DOMAINS.concat(vip ? vip.domains : []), + ), } - return ctx } -export function useCanGoLive(did?: string) { - const config = useLiveNowConfig() - return !!config.find(cfg => cfg.did === did) +export function useCanGoLive() { + const gate = useGate() + const {hasSession} = useSession() + if (!hasSession) return false + return IS_DEV ? true : gate('live_now_beta') } export function useCheckEmailConfirmed() { diff --git a/src/view/com/feeds/ComposerPrompt.tsx b/src/view/com/feeds/ComposerPrompt.tsx index be88ee2dc1..5d4685a4e6 100644 --- a/src/view/com/feeds/ComposerPrompt.tsx +++ b/src/view/com/feeds/ComposerPrompt.tsx @@ -148,8 +148,6 @@ export function ComposerPrompt() { a.relative, a.flex_row, a.align_start, - a.border_t, - t.atoms.border_contrast_low, { paddingLeft: 18, paddingRight: 15, diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 4f25468c95..97e5e9731e 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -70,6 +70,7 @@ import { } from '#/components/feeds/PostFeedVideoGridRow' import {TrendingInterstitial} from '#/components/interstitials/Trending' import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' +import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner' import {ComposerPrompt} from '../feeds/ComposerPrompt' import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' import {FeedShutdownMsg} from './FeedShutdownMsg' @@ -155,6 +156,10 @@ type FeedRow = type: 'composerPrompt' key: string } + | { + type: 'liveEventFeedsAndTrendingBanner' + key: string + } export function getItemsForFeedback(feedRow: FeedRow): { item: FeedPostSliceItem @@ -360,7 +365,7 @@ let PostFeed = ({ const showProgressIntersitial = (followProgressGuide || followAndLikeProgressGuide) && !rightNavVisible - const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings() + const {trendingVideoDisabled} = useTrendingSettings() const ageAssuranceBannerState = useAgeAssuranceBannerState() const selectedFeed = useSelectedFeed() @@ -510,13 +515,10 @@ let PostFeed = ({ }) } } - if (!rightNavVisible && !trendingDisabled) { - arr.push({ - type: 'interstitialTrending', - key: - 'interstitial2-' + sliceIndex + '-' + lastFetchedAt, - }) - } + arr.push({ + type: 'liveEventFeedsAndTrendingBanner', + key: 'liveEventFeedsAndTrendingBanner-' + sliceIndex, + }) // Show composer prompt for Discover and Following feeds if ( hasSession && @@ -672,9 +674,7 @@ let PostFeed = ({ feedTab, hasSession, showProgressIntersitial, - trendingDisabled, trendingVideoDisabled, - rightNavVisible, gtMobile, isVideoFeed, areVideoFeedsEnabled, @@ -773,6 +773,8 @@ let PostFeed = ({ return } else if (row.type === 'interstitialTrending') { return + } else if (row.type === 'liveEventFeedsAndTrendingBanner') { + return } else if (row.type === 'composerPrompt') { return } else if (row.type === 'interstitialTrendingVideos') { @@ -952,7 +954,7 @@ let PostFeed = ({ const actor = post.author if ( actor.status && - validateStatus(actor.did, actor.status, liveNowConfig) && + validateStatus(actor.status, liveNowConfig) && isStatusStillActive(actor.status.expiresAt) ) { if (!seenActorWithStatusRef.current.has(actor.did)) { diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index 244ebde05f..5b6ce7841a 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -21,6 +21,7 @@ import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' import {useGate} from '#/lib/statsig/statsig' import {countLines} from '#/lib/strings/helpers' +import {logger} from '#/logger' import { POST_TOMBSTONE, type Shadow, @@ -173,7 +174,8 @@ let FeedItemInner = ({ const urip = new AtUri(post.uri) return [makeProfileLink(post.author, 'post', urip.rkey), urip.rkey] }, [post.uri, post.author]) - const {sendInteraction, feedSourceInfo} = useFeedFeedbackContext() + const {sendInteraction, feedSourceInfo, feedDescriptor} = + useFeedFeedbackContext() const onPressReply = () => { sendInteraction({ @@ -209,6 +211,12 @@ let FeedItemInner = ({ feedContext, reqId, }) + logger.metric('post:clickthroughAuthor', { + uri: post.uri, + authorDid: post.author.did, + logContext: 'FeedItem', + feedDescriptor, + }) } const onOpenReposter = () => { @@ -227,6 +235,12 @@ let FeedItemInner = ({ feedContext, reqId, }) + logger.metric('post:clickthroughEmbed', { + uri: post.uri, + authorDid: post.author.did, + logContext: 'FeedItem', + feedDescriptor, + }) } const onBeforePress = () => { @@ -236,6 +250,12 @@ let FeedItemInner = ({ feedContext, reqId, }) + logger.metric('post:clickthroughItem', { + uri: post.uri, + authorDid: post.author.did, + logContext: 'FeedItem', + feedDescriptor, + }) unstableCacheProfileView(queryClient, post.author) setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), { feedSourceInfo, diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 8cf6d2c968..f9c1bac007 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -15,6 +15,7 @@ import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import {type Shadow} from '#/state/cache/types' import {useModalControls} from '#/state/modals' +import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' import { RQKEY as profileQueryKey, useProfileBlockMutationQueue, @@ -25,6 +26,7 @@ import {useCanGoLive} from '#/state/service-config' import {useSession} from '#/state/session' import {EventStopper} from '#/view/com/util/EventStopper' import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog' @@ -59,6 +61,8 @@ import * as Prompt from '#/components/Prompt' import {useFullVerificationState} from '#/components/verification' import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt' import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' +import {Dot} from '#/features/nuxs/components/Dot' +import {Gradient} from '#/features/nuxs/components/Gradient' import {useDevMode} from '#/storage/hooks/dev-mode' let ProfileMenu = ({ @@ -66,6 +70,7 @@ let ProfileMenu = ({ }: { profile: Shadow }): React.ReactNode => { + const t = useTheme() const {_} = useLingui() const {currentAccount, hasSession} = useSession() const {openModal} = useModalControls() @@ -79,8 +84,15 @@ let ProfileMenu = ({ const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked const [devModeEnabled] = useDevMode() const verification = useFullVerificationState({profile}) - const canGoLive = useCanGoLive(currentAccount?.did) + const canGoLive = useCanGoLive() const status = useActorStatus(profile) + const statusNudge = useNux(Nux.LiveNowBetaNudge) + const statusNudgeActive = + isSelf && + canGoLive && + statusNudge.status === 'ready' && + !statusNudge.nux?.completed + const {mutate: saveNux} = useSaveNux() const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) @@ -229,17 +241,22 @@ let ProfileMenu = ({ {({props}) => { return ( - + <> + + + {statusNudgeActive && } + ) }} @@ -337,11 +354,19 @@ let ProfileMenu = ({ ? _(msg`Edit live status`) : _(msg`Go live`) } - onPress={ - status.isDisabled - ? goLiveDisabledDialogControl.open - : goLiveDialogControl.open - }> + onPress={() => { + if (status.isDisabled) { + goLiveDisabledDialogControl.open() + } else { + goLiveDialogControl.open() + } + saveNux({ + id: Nux.LiveNowBetaNudge, + data: undefined, + completed: true, + }) + }}> + {statusNudgeActive && } {status.isDisabled ? ( Go live (disabled) @@ -351,7 +376,26 @@ let ProfileMenu = ({ Go live )} - + {statusNudgeActive && ( + + New + + )} + t.palette.primary_500 + : undefined + } + /> )} {verification.viewer.role === 'verifier' && diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index 788df6e64c..04dddaf739 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -22,6 +22,7 @@ import {CENTER_COLUMN_OFFSET} from '#/components/Layout' import {InlineLinkText} from '#/components/Link' import {ProgressGuideList} from '#/components/ProgressGuide/List' import {Text} from '#/components/Typography' +import {SidebarLiveEventFeedsBanner} from '#/features/liveEvents/components/SidebarLiveEventFeedsBanner' function useWebQueryParams() { const navigation = useNavigation() @@ -49,7 +50,8 @@ export function DesktopRightNav({routeName}: {routeName: string}) { const isSearchScreen = routeName === 'Search' const webqueryParams = useWebQueryParams() const searchQuery = webqueryParams?.q - const showTrending = !isSearchScreen || (isSearchScreen && !!searchQuery) + const showExploreScreenDuplicatedContent = + !isSearchScreen || (isSearchScreen && !!searchQuery) const {rightNavVisible, centerColumnOffset, leftNavMinimal} = useLayoutBreakpoints() @@ -90,7 +92,8 @@ export function DesktopRightNav({routeName}: {routeName: string}) { )} - {showTrending && } + {showExploreScreenDuplicatedContent && } + {showExploreScreenDuplicatedContent && } {hasSession && ( diff --git a/yarn.lock b/yarn.lock index 16a78ababf..51b7d366c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -82,12 +82,12 @@ "@atproto/xrpc" "^0.7.6" "@atproto/xrpc-server" "^0.10.0" -"@atproto/api@^0.18.13": - version "0.18.13" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.13.tgz#63eee310e6715752eb87748323cf9ab57dd91e4b" - integrity sha512-CULZ01pSJDltLS/Gc9MMrhFzB6OM3ezyZw7KoeLT/sBfwgA1ddA4mWdTh7DIRosPRigXtA05bnoiCutZbQDo+Q== +"@atproto/api@^0.18.13", "@atproto/api@^0.18.15": + version "0.18.15" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.15.tgz#25ce82081216bdefbf5397220de76ac3ba9e3f5d" + integrity sha512-GeaTP7HMRZa8jD6trMuTACa8t2jkFtRmcwWgrB0FT7l9jVCXrKpYupWeIeauEgWHNwWUUiaq3LmCox+HBy8ZMQ== dependencies: - "@atproto/common-web" "^0.4.11" + "@atproto/common-web" "^0.4.12" "@atproto/lexicon" "^0.6.0" "@atproto/syntax" "^0.4.2" "@atproto/xrpc" "^0.7.7" @@ -194,16 +194,7 @@ pino-http "^8.2.1" typed-emitter "^2.1.0" -"@atproto/common-web@^0.4.11": - version "0.4.11" - resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.11.tgz#eb41dc02c1ea4221388630e193d181fb098186e0" - integrity sha512-VHejNmSABU8/03VrQ3e36AmT5U3UIeio+qSUqCrO1oNgrJcWfGy1rpj0FVtUugWF8Un29+yzkukzWGZfXL70rQ== - dependencies: - "@atproto/lex-data" "0.0.7" - "@atproto/lex-json" "0.0.7" - zod "^3.23.8" - -"@atproto/common-web@^0.4.12": +"@atproto/common-web@^0.4.11", "@atproto/common-web@^0.4.12": version "0.4.12" resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.12.tgz#04135bef480d9e12cfef124ee45d8236764e7509" integrity sha512-3aCJemqM/fkHQrVPbTCHCdiVstKFI+2LkFLvUhO6XZP0EqUZa/rg/CIZBKTFUWu9I5iYiaEiXL9VwcDRpEevSw== @@ -437,17 +428,6 @@ uint8arrays "3.0.0" unicode-segmenter "^0.14.0" -"@atproto/lex-data@0.0.7": - version "0.0.7" - resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.7.tgz#6aa87423f6d47849bec8ff3ca0b00ce93964adc8" - integrity sha512-W/Q5o9o7n2Sv3UywckChu01X5lwQUtaiiOkGJLnRsdkQTyC6813nPgY+p2sG7NwwM+82lu+FUV9fE/Ul3VqaJw== - dependencies: - "@atproto/syntax" "0.4.2" - multiformats "^9.9.0" - tslib "^2.8.1" - uint8arrays "3.0.0" - unicode-segmenter "^0.14.0" - "@atproto/lex-data@0.0.8", "@atproto/lex-data@^0.0.8": version "0.0.8" resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.8.tgz#46cc261efbfa6cc05bf04439d2d73cd8386b467d" @@ -484,14 +464,6 @@ "@atproto/lex-data" "0.0.3" tslib "^2.8.1" -"@atproto/lex-json@0.0.7": - version "0.0.7" - resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.7.tgz#c06e1fc3e06d739bbb74694f5d846055bed37866" - integrity sha512-bjNPD5M/MhLfjNM7tcxuls80UgXpHqxdOxDXEUouAtZQV/nIDhGjmNUvKxOmOgnDsiZRnT2g5y3onrnjH3a44g== - dependencies: - "@atproto/lex-data" "0.0.7" - tslib "^2.8.1" - "@atproto/lex-json@0.0.8": version "0.0.8" resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.8.tgz#03290762d9368b029488ee0a0766d1a34063255c" @@ -6212,20 +6184,13 @@ resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb" integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw== -"@react-native-async-storage/async-storage@2.2.0": +"@react-native-async-storage/async-storage@2.2.0", "@react-native-async-storage/async-storage@^1.15.2": version "2.2.0" resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz#a3aa565253e46286655560172f4e366e8969f5ad" integrity sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw== dependencies: merge-options "^3.0.4" -"@react-native-async-storage/async-storage@^1.15.2": - version "1.22.0" - resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-1.22.0.tgz#202a9afd15a5b829c39b709d0ca3942612441efc" - integrity sha512-b5KD010iiZnot86RbAaHpLuHwmPW2qA3SSN/OSZhd1kBoINEQEVBuv+uFtcaTxAhX27bT0wd13GOb2IOSDUXSA== - dependencies: - merge-options "^3.0.4" - "@react-native/assets-registry@0.81.5": version "0.81.5" resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.81.5.tgz#d22c924fa6f6d4a463c5af34ce91f38756c0fa7d"