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

# Conflicts:
#	src/analytics/features/types.ts
This commit is contained in:
vineyardbovines
2026-07-23 09:01:58 -04:00
115 changed files with 5172 additions and 2360 deletions
+9
View File
@@ -117,6 +117,7 @@ import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
import {LegacyNotificationSettingsScreen} from '#/screens/Settings/LegacyNotificationSettings'
import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
import {ActivityNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ActivityNotificationSettings'
import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings'
import {SettingsScreen} from '#/screens/Settings/Settings'
import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences'
@@ -451,6 +452,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => NotificationSettingsScreen}
options={{title: title(msg`Notification settings`), requireAuth: true}}
/>
<Stack.Screen
name="ActivityNotificationSettings"
getComponent={() => ActivityNotificationSettingsScreen}
options={{
title: title(msg`Activity notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="ContentAndMediaSettings"
getComponent={() => ContentAndMediaSettingsScreen}
+1
View File
@@ -20,6 +20,7 @@ export enum Features {
PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable',
CustomLogoJapanEnable = 'custom_logo:japan:enable',
VideoMultipartUploadEnable = 'video:multipart_upload:enable',
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
AATest = 'aa-test',
}
+30 -12
View File
@@ -513,7 +513,7 @@ export type Events = {
| 'ProgressGuide'
location: 'Card' | 'Profile' | 'FollowAll'
recSource?: 'Search'
recId?: number | string
recId?: string
position: number
suggestedDid: string
category: string | null
@@ -526,7 +526,7 @@ export type Events = {
| 'ProfileHeader'
| 'Onboarding'
| 'SeeMoreSuggestedUsers'
recId?: number | string
recId?: string
position: number
suggestedDid: string
category: string | null
@@ -541,7 +541,7 @@ export type Events = {
| 'SeeMoreSuggestedUsers'
| 'ProgressGuide'
recSource?: 'Search'
recId?: number | string
recId?: string
position: number
suggestedDid: string
category: string | null
@@ -553,11 +553,11 @@ export type Events = {
| 'ProfileInterstitial'
| 'ProfileHeader'
| 'Onboarding'
recId?: number | string
recId?: string
}
'suggestedUser:dismiss': {
logContext: 'DiscoverInterstitial' | 'ProfileInterstitial' | 'ProfileHeader'
recId?: number | string
recId?: string
position: number
suggestedDid: string
}
@@ -608,7 +608,7 @@ export type Events = {
// Group chat adoption
'groupchat:create': {
logContext: 'NewChatDialog'
logContext: 'NewChatDialog' | 'SendViaChatDialog'
}
'groupchat:landingPage:view': {
hasSession: boolean
@@ -746,9 +746,7 @@ export type Events = {
}
'trendingTopic:click': {
context: 'sidebar' | 'interstitial' | 'explore'
}
'recommendedTopic:click': {
context: 'explore'
recId?: string
}
'trendingVideos:show': {
context: 'settings'
@@ -782,13 +780,13 @@ export type Events = {
}
'search:results:loaded': {
tab: 'top' | 'latest' | 'people' | 'feeds'
tab: 'top' | 'latest' | 'people' | 'feeds' | 'starterPacks'
initialCount: number
}
'search:result:press': {
tab?: 'top' | 'latest' | 'people' | 'feeds'
resultType: 'post' | 'profile' | 'feed'
tab?: 'top' | 'latest' | 'people' | 'feeds' | 'starterPacks'
resultType: 'post' | 'profile' | 'feed' | 'starterPack'
position: number
uri: string
}
@@ -1349,6 +1347,26 @@ export type Events = {
// user dismissed the empty-followers promo banner
'invite:followersPromo:dismiss': {}
/**
* Fired when a video fails terminally during playback: unreachable (404),
* undecodable, or the client lacks the required codecs. Complements the
* Sentry-only video.playback spans with a countable, unsampled event.
*/
'video:playback:failed': {
surface: 'feed' | 'immersiveFeed'
presentation: 'video' | 'gif'
/**
* Coarse failure bucket: VideoNotFoundError, HLSUnsupportedError, an
* hls.js error details code (e.g. bufferAppendError), or PlayerError on
* native.
*/
errorClass: string
/** Truncated to 256 chars */
errorMessage: string
/** HLS playlist URL, identifies the exact video for server-side lookup */
playlist: string
}
// === Video upload funnel (Frontend Spec section D) ===
// Every event carries uploadId (client-generated UUID, ties one upload
// session end-to-end) + engine (compression engine id, e.g.
+104
View File
@@ -0,0 +1,104 @@
import {useState} from 'react'
import {type Insets, Pressable, View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
import * as Tooltip from '#/components/Tooltip'
import type * as bsky from '#/types/bsky'
/**
* Whether to show the beta badge for a given profile. Only shown on the
* viewer's own profile, and only when the viewer has opted in to beta features.
*/
export function useIsBetaBadgeVisible(
profile: bsky.profile.AnyProfileView,
): boolean {
const {currentAccount} = useSession()
const {data: preferences} = usePreferencesQuery()
const isBetaUser = preferences?.bskyAppState?.isBetaUser ?? false
const isSelf = currentAccount?.did === profile.did
return isSelf && isBetaUser
}
export function BetaBadge({
profile,
width,
padding,
}: {
profile: bsky.profile.AnyProfileView
width: number
padding: number
}) {
const t = useTheme()
const isVisible = useIsBetaBadgeVisible(profile)
if (!isVisible) return null
return (
<View
style={[
a.rounded_full,
{backgroundColor: t.palette.primary_50, padding},
]}>
<BeakerIcon width={width} fill={t.palette.primary_500} />
</View>
)
}
export function BetaBadgeButton({
profile,
width,
padding,
hitSlop,
}: {
profile: bsky.profile.AnyProfileView
width: number
padding: number
hitSlop: Insets
}) {
const t = useTheme()
const {t: l} = useLingui()
const isVisible = useIsBetaBadgeVisible(profile)
const [tooltipVisible, setTooltipVisible] = useState(false)
if (!isVisible) return null
return (
<Tooltip.Outer
color="primary"
visible={tooltipVisible}
onVisibleChange={setTooltipVisible}>
<Tooltip.Target>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Beta features enabled`}
accessibilityHint=""
hitSlop={hitSlop}
style={({hovered}) => [
a.rounded_full,
a.transition_transform,
{
backgroundColor: t.palette.primary_50,
padding,
transform: [
{
scale: hovered ? 1.1 : 1,
},
],
},
]}
onPress={() => setTooltipVisible(v => !v)}>
<BeakerIcon width={width} fill={t.palette.primary_500} />
</Pressable>
</Tooltip.Target>
<Tooltip.BubbleText label={l`Beta features enabled`}>
<Trans>Beta features enabled</Trans>
</Tooltip.BubbleText>
</Tooltip.Outer>
)
}
+4 -2
View File
@@ -1,4 +1,4 @@
import {View} from 'react-native'
import {type Insets, View} from 'react-native'
import {type ComAtprotoLabelDefs} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
@@ -44,9 +44,11 @@ export function BotBadge({
export function BotBadgeButton({
profile,
width,
hitSlop,
}: {
profile: bsky.profile.AnyProfileView
width: number
hitSlop: Insets
}) {
const t = useTheme()
const ax = useAnalytics()
@@ -61,7 +63,7 @@ export function BotBadgeButton({
<>
<Button
label={l`Automated account`}
hitSlop={20}
hitSlop={hitSlop}
onPress={evt => {
evt.preventDefault()
ax.metric('bot:badge:click', {})
+1 -1
View File
@@ -286,7 +286,7 @@ export function Composer({
ref={IS_WEB ? sift.refs.setAnchor : undefined}
style={
node.type === 'facet' && {
color: t.palette.primary_500,
color: t.atoms.text_link.color,
}
}>
{node.raw}
+2 -1
View File
@@ -157,7 +157,8 @@ export function Outer({
[open, close],
)
const isHeightConstrained = nativeOptions?.maxHeight != null
const isHeightConstrained =
nativeOptions?.maxHeight != null || nativeOptions?.fullHeight === true
const context = useMemo(
() => ({
+2 -2
View File
@@ -481,11 +481,11 @@ export function ProfileGrid({
<Text
style={[
a.text_sm,
{color: t.palette.primary_500},
t.atoms.text_link,
hovered &&
web({
textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500,
textDecorationColor: t.atoms.text_link.color,
}),
]}>
<Trans>See more</Trans>
@@ -78,6 +78,7 @@ export function ImageMenu({onPressShare, onPressSave}: Props) {
visible={isMounted}
animationType="none"
onRequestClose={close}
supportedOrientations={['portrait', 'landscape']}
statusBarTranslucent>
<Pressable
accessibilityRole="button"
+5 -5
View File
@@ -415,7 +415,7 @@ function LinkPeek({
// dialog can show.
useInAppBrowser: useInAppBrowserPref === true,
browserToolbarColor: t.atoms.bg.backgroundColor,
browserControlsColor: t.palette.primary_500,
browserControlsColor: t.atoms.text_link.color,
}}
borderRadius={borderRadius}
// Fires only when not morphing natively (in-app browser off/unset).
@@ -487,14 +487,14 @@ export function InlineLinkText({
accessibilityLabel={label}
{...rest}
style={[
{color: t.palette.primary_500},
t.atoms.text_link,
interacted &&
!disableUnderline && {
...web({
outline: 0,
textDecorationLine: 'underline',
textDecorationColor:
flattenedStyle.color ?? t.palette.primary_500,
flattenedStyle.color ?? t.atoms.text_link.color,
}),
},
flattenedStyle,
@@ -591,14 +591,14 @@ export function SimpleInlineLinkText({
accessibilityLabel={label}
{...rest}
style={[
{color: t.palette.primary_500},
t.atoms.text_link,
interacted &&
!disableUnderline && {
...web({
outline: 0,
textDecorationLine: 'underline',
textDecorationColor:
flattenedStyle.color ?? t.palette.primary_500,
flattenedStyle.color ?? t.atoms.text_link.color,
}),
},
flattenedStyle,
+5 -6
View File
@@ -59,7 +59,7 @@ export function ImageEmbed({
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
// ref + dims that a tap would — keeps the lightbox's return animation intact.
const singleContainerRef = useRef<AnimatedRef<any> | null>(null)
const singleContainerRef = useRef<AnimatedRef<React.Component> | null>(null)
const singleDimsRef = useRef<Dimensions | null>(null)
if (images.length > 0) {
@@ -71,7 +71,7 @@ export function ImageEmbed({
}))
const onPress = (
index: number,
refs: AnimatedRef<any>[],
refs: AnimatedRef<React.Component>[],
fetchedDims: (Dimensions | null)[],
) => {
if (postContext) {
@@ -97,7 +97,7 @@ export function ImageEmbed({
}
const onPressIn = (_: number) => {
InteractionManager.runAfterInteractions(() => {
Image.prefetch(
void Image.prefetch(
items.map(i => i.uri),
'memory',
)
@@ -115,6 +115,7 @@ export function ImageEmbed({
onPress(0, [singleContainerRef.current], [singleDimsRef.current])
}
}
return (
<View style={[a.mt_sm, rest.style]}>
<ImageContextMenu
@@ -127,9 +128,7 @@ export function ImageEmbed({
crop={
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: rest.isWithinQuote
? 'square'
: 'constrained'
: 'constrained'
}
image={image}
onContainerRef={ref => {
@@ -1,6 +1,7 @@
import {type VideoEmbedInnerWebProps} from './VideoEmbedInnerWeb.shared'
export {
HLSFatalError,
HLSUnsupportedError,
VideoNotFoundError,
} from './VideoEmbedInnerWeb.shared'
@@ -19,3 +19,16 @@ export class VideoNotFoundError extends Error {
super('Video not found')
}
}
/**
* Fatal hls.js playback error. `detail` is the hls.js error details code
* (e.g. bufferAppendError), which buckets failures more usefully than the
* error message.
*/
export class HLSFatalError extends Error {
detail: string
constructor(detail: string, cause: Error) {
super(cause.message, {cause})
this.detail = detail
}
}
@@ -10,6 +10,7 @@ import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useFullscreen} from '#/components/hooks/useFullscreen'
import * as BandwidthEstimate from './bandwidth-estimate'
import {
HLSFatalError,
HLSUnsupportedError,
type VideoEmbedInnerWebProps,
VideoNotFoundError,
@@ -17,6 +18,7 @@ import {
import {Controls} from './web-controls/VideoControls'
export {
HLSFatalError,
HLSUnsupportedError,
VideoNotFoundError,
} from './VideoEmbedInnerWeb.shared'
@@ -306,7 +308,7 @@ function useHLS({
) {
setError(new VideoNotFoundError())
} else {
setError(data.error)
setError(new HLSFatalError(data.details, data.error))
}
} else {
console.error(data.error)
@@ -16,6 +16,7 @@ import {Button} from '#/components/Button'
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {useAnalytics} from '#/analytics'
import {GifPresentationControls} from './GifPresentationControls'
import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
@@ -70,6 +71,7 @@ export function VideoEmbed({embed}: Props) {
function InnerWrapper({embed}: Props) {
const {_} = useLingui()
const ax = useAnalytics()
const ref = useRef<{togglePlayback: () => void}>(null)
const [status, setStatus] = useState<'playing' | 'paused' | 'pending'>(
@@ -130,6 +132,13 @@ function InnerWrapper({embed}: Props) {
}}
onError={error => {
telemetryRef.current?.error(error)
ax.metric('video:playback:failed', {
surface: 'feed',
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
errorClass: 'PlayerError',
errorMessage: error.slice(0, 256),
playlist: embed.playlist,
})
}}
ref={ref}
/>
@@ -18,16 +18,25 @@ import {useFullscreen} from '#/components/hooks/useFullscreen'
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {
HLSFatalError,
HLSUnsupportedError,
VideoEmbedInnerWeb,
VideoNotFoundError,
} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb'
import {useAnalytics} from '#/analytics'
import {IS_WEB_FIREFOX} from '#/env'
import {useActiveVideoWeb} from './ActiveVideoWebContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
const noop = () => {}
/**
* Minimum card width for the overlay controls (play, time, CC, volume,
* fullscreen) to fit without crowding. Narrower cards fall back to the
* full-width pillarbox.
*/
const MIN_CARD_WIDTH = 280
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
const t = useTheme()
const ref = useRef<HTMLDivElement>(null)
@@ -69,9 +78,9 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
const [key, setKey] = useState(0)
const renderError = useCallback(
(error: unknown) => (
<VideoError error={error} retry={() => setKey(key + 1)} />
<VideoError embed={embed} error={error} retry={() => setKey(key + 1)} />
),
[key],
[key, embed],
)
let aspectRatio: number | undefined
@@ -89,6 +98,21 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
constrained = Math.max(aspectRatio, ratio)
}
const [containerWidth, setContainerWidth] = useState(0)
/*
* Portrait videos render at their own ratio instead of pillarboxed, but
* only when the resulting card fits the overlay controls. Videos taller
* than 1:2 would still show bars inside a ratio-fit card, and an unknown
* ratio can't be fit, so both keep the full-width pillarbox - a narrow
* card with black slices down the sides looks broken (see #9371).
*/
const cardWidth = containerWidth * Math.min(aspectRatio ?? 1, 1)
const fullBleed =
aspectRatio === undefined ||
aspectRatio < 1 / 2 ||
(containerWidth > 0 && cardWidth < MIN_CARD_WIDTH)
const contents = (
<div
ref={ref}
@@ -96,6 +120,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
display: 'flex',
flex: 1,
cursor: 'default',
position: 'relative',
backgroundColor: t.palette.black,
backgroundImage: `url(${embed.thumbnail})`,
backgroundSize: 'contain',
@@ -103,6 +128,36 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
backgroundRepeat: 'no-repeat',
}}
onClick={evt => evt.stopPropagation()}>
{fullBleed && embed.thumbnail && (
<>
{/* blurred backdrop fills the bars when the video is boxed */}
<div
aria-hidden
style={{
position: 'absolute',
inset: 0,
backgroundImage: `url(${embed.thumbnail})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
filter: 'blur(32px)',
// hide the transparent fade the blur creates at the edges
transform: 'scale(1.2)',
}}
/>
{/* redraw the sharp thumbnail above the blur */}
<div
aria-hidden
style={{
position: 'absolute',
inset: 0,
backgroundImage: `url(${embed.thumbnail})`,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
/>
</>
)}
<ErrorBoundary renderError={renderError} key={key}>
<OnlyNearScreen>
<VideoEmbedInnerWeb
@@ -118,12 +173,14 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
)
return (
<View style={[a.pt_xs]}>
<View
style={[a.pt_xs]}
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}>
<ViewportObserver
sendPosition={isGif ? noop : sendPosition}
isAnyViewActive={currentActiveView !== null}>
<ConstrainedImage
fullBleed
fullBleed={fullBleed}
aspectRatio={constrained || 1}
// slightly smaller max height than images
// images use 16 / 9, for reference
@@ -222,23 +279,63 @@ export const OnlyNearScreen = ({children}: {children: React.ReactNode}) => {
return nearScreen ? children : null
}
function VideoError({error, retry}: {error: unknown; retry: () => void}) {
function VideoError({
embed,
error,
retry,
}: {
embed: AppBskyEmbedVideo.View
error: unknown
retry: () => void
}) {
const {_} = useLingui()
const ax = useAnalytics()
let showRetryButton = true
let text = null
let errorClass: string
if (error instanceof VideoNotFoundError) {
text = _(msg`Video not found.`)
errorClass = 'VideoNotFoundError'
} else if (error instanceof HLSUnsupportedError) {
showRetryButton = false
text = _(
msg`This video cant be played on your device. Your browser or system may be missing the required video codecs (H.264/AAC).`,
)
errorClass = 'HLSUnsupportedError'
} else {
text = _(msg`An error occurred while loading the video. Please try again.`)
if (error instanceof HLSFatalError) {
errorClass = error.detail
} else if (error instanceof Error) {
errorClass = error.name || 'Error'
} else {
errorClass = 'Unknown'
}
}
const errorMessage = error instanceof Error ? error.message : String(error)
const presentation = embed.presentation === 'gif' ? 'gif' : 'video'
const playlist = embed.playlist
/*
* Fire exactly once per failure - the analytics context identity can change
* (session/geolocation updates) while this fallback stays mounted, which
* would otherwise re-run the effect and double-count.
*/
const fired = useRef(false)
useEffect(() => {
if (fired.current) return
fired.current = true
ax.metric('video:playback:failed', {
surface: 'feed',
presentation,
errorClass,
errorMessage: errorMessage.slice(0, 256),
playlist,
})
}, [ax, presentation, playlist, errorClass, errorMessage])
return (
<VideoFallback.Container>
<VideoFallback.Text>{text}</VideoFallback.Text>
-1
View File
@@ -4,7 +4,6 @@ import {type AppBskyFeedDefs, type ModerationDecision} from '@atproto/api'
export enum PostEmbedViewContext {
ThreadHighlighted = 'ThreadHighlighted',
Feed = 'Feed',
FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
ChatMessage = 'ChatMessage',
}
+5 -7
View File
@@ -1,8 +1,6 @@
import {useCallback, useMemo} from 'react'
import {LayoutAnimation, type TextStyle} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {HITSLOP_10} from '#/lib/constants'
import {atoms as a, flatten, type TextStyleProp, useTheme} from '#/alf'
@@ -14,7 +12,7 @@ export function ShowMoreTextButton({
style,
}: TextStyleProp & {onPress: () => void}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const onPress = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
@@ -30,7 +28,7 @@ export function ShowMoreTextButton({
return (
<Button
label={_(msg`Expand post text`)}
label={l`Expand post text`}
onPress={onPress}
style={[
a.self_start,
@@ -43,13 +41,13 @@ export function ShowMoreTextButton({
<Text
style={[
textStyle,
t.atoms.text_link,
{
color: t.palette.primary_500,
opacity: pressed ? 0.6 : 1,
textDecorationLine: hovered ? 'underline' : undefined,
},
]}>
<Trans>Show More</Trans>
<Trans>Show more</Trans>
</Text>
)}
</Button>
+4 -4
View File
@@ -150,10 +150,10 @@ function TranslationLink({
label={l`Translate`}
hoverStyle={[
native({opacity: 0.5}),
web([a.underline, {textDecorationColor: t.palette.primary_500}]),
web([a.underline, {textDecorationColor: t.atoms.text_link.color}]),
]}
hitSlop={HITSLOP_30}>
<Text style={[a.text_sm, {color: t.palette.primary_500}]}>
<Text style={[a.text_sm, t.atoms.text_link]}>
<Trans>Translate</Trans>
</Text>
</Link>
@@ -229,7 +229,7 @@ function TranslationError({
label={l`Try Google Translate`}
hoverStyle={[
native({opacity: 0.5}),
web([a.underline, {textDecorationColor: t.palette.primary_500}]),
web([a.underline, {textDecorationColor: t.atoms.text_link.color}]),
]}
hitSlop={HITSLOP_30}>
<Text
@@ -237,7 +237,7 @@ function TranslationError({
a.text_xs,
a.font_medium,
a.leading_snug,
{color: t.palette.primary_500},
t.atoms.text_link,
]}>
<Trans>Try Google Translate</Trans>
</Text>
@@ -1,9 +1,7 @@
import {memo, useMemo} from 'react'
import * as ExpoClipboard from 'expo-clipboard'
import {AtUri} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
@@ -37,7 +35,7 @@ let ShareMenuItems = ({
}: ShareMenuItemsProps): React.ReactNode => {
const ax = useAnalytics()
const {hasSession} = useSession()
const {_} = useLingui()
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const sendViaChatControl = useDialogControl()
const [devModeEnabled] = useDevMode()
@@ -61,7 +59,7 @@ let ShareMenuItems = ({
const onSharePost = () => {
ax.metric('share:press:nativeShare', {})
const url = toShareUrl(href)
shareUrl(url)
void shareUrl(url)
onShareProp()
}
@@ -74,7 +72,7 @@ let ShareMenuItems = ({
} else {
await ExpoClipboard.setStringAsync(url)
}
Toast.show(_(msg`Copied to clipboard`), {
Toast.show(l`Copied to clipboard`, {
type: 'success',
})
onShareProp()
@@ -93,11 +91,11 @@ let ShareMenuItems = ({
}
const onShareATURI = () => {
shareText(postUri)
void shareText(postUri)
}
const onShareAuthorDID = () => {
shareText(postAuthor.did)
void shareText(postAuthor.did)
}
return (
@@ -113,13 +111,13 @@ let ShareMenuItems = ({
</Menu.ContainerItem>
<Menu.Item
testID="postDropdownSendViaDMBtn"
label={_(msg`Send via direct message`)}
label={l`Send via chat`}
onPress={() => {
ax.metric('share:press:openDmSearch', {})
sendViaChatControl.open()
}}>
<Menu.ItemText>
<Trans>Send via direct message</Trans>
<Trans>Send via chat</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={PaperPlaneIcon} position="right" />
</Menu.Item>
@@ -129,7 +127,7 @@ let ShareMenuItems = ({
<Menu.Group>
<Menu.Item
testID="postDropdownShareBtn"
label={_(msg`Share via...`)}
label={l`Share via...`}
onPress={onSharePost}>
<Menu.ItemText>
<Trans>Share via...</Trans>
@@ -139,8 +137,8 @@ let ShareMenuItems = ({
<Menu.Item
testID="postDropdownShareBtn"
label={_(msg`Copy link to post`)}
onPress={onCopyLink}>
label={l`Copy link to post`}
onPress={() => void onCopyLink()}>
<Menu.ItemText>
<Trans>Copy link to post</Trans>
</Menu.ItemText>
@@ -164,7 +162,7 @@ let ShareMenuItems = ({
<Menu.Group>
<Menu.Item
testID="postAtUriShareBtn"
label={_(msg`Share post at:// URI`)}
label={l`Share post at:// URI`}
onPress={onShareATURI}>
<Menu.ItemText>
<Trans>Share post at:// URI</Trans>
@@ -173,7 +171,7 @@ let ShareMenuItems = ({
</Menu.Item>
<Menu.Item
testID="postAuthorDIDShareBtn"
label={_(msg`Share author DID`)}
label={l`Share author DID`}
onPress={onShareAuthorDID}>
<Menu.ItemText>
<Trans>Share author DID</Trans>
@@ -183,7 +181,6 @@ let ShareMenuItems = ({
</Menu.Group>
)}
</Menu.Outer>
<SendViaChatDialog
control={sendViaChatControl}
onSelectChat={onSelectChatToShareTo}
@@ -1,8 +1,6 @@
import {memo, useMemo} from 'react'
import {AtUri} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {makeProfileLink} from '#/lib/routes/links'
@@ -35,7 +33,7 @@ let ShareMenuItems = ({
const ax = useAnalytics()
const {hasSession} = useSession()
const {gtMobile} = useBreakpoints()
const {_} = useLingui()
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const embedPostControl = useDialogControl()
const sendViaChatControl = useDialogControl()
@@ -60,7 +58,7 @@ let ShareMenuItems = ({
const onCopyLink = () => {
ax.metric('share:press:copyLink', {})
const url = toShareUrl(href)
shareUrl(url)
void shareUrl(url)
onShareProp()
}
@@ -75,17 +73,17 @@ let ShareMenuItems = ({
const canEmbed = IS_WEB && gtMobile && !hideInPWI
const onShareATURI = () => {
shareText(postUri)
void shareText(postUri)
}
const onShareAuthorDID = () => {
shareText(postAuthor.did)
void shareText(postAuthor.did)
}
const copyLinkItem = (
<Menu.Item
testID="postDropdownShareBtn"
label={_(msg`Copy link to post`)}
label={l`Copy link to post`}
onPress={onCopyLink}>
<Menu.ItemText>
<Trans>Copy link to post</Trans>
@@ -102,13 +100,13 @@ let ShareMenuItems = ({
{hasSession && aa.state.access === aa.Access.Full && (
<Menu.Item
testID="postDropdownSendViaDMBtn"
label={_(msg`Send via direct message`)}
label={l`Send via chat`}
onPress={() => {
ax.metric('share:press:openDmSearch', {})
sendViaChatControl.open()
}}>
<Menu.ItemText>
<Trans>Send via direct message</Trans>
<Trans>Send via chat</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Send} position="right" />
</Menu.Item>
@@ -117,12 +115,12 @@ let ShareMenuItems = ({
{canEmbed && (
<Menu.Item
testID="postDropdownEmbedBtn"
label={_(msg`Embed post`)}
label={l`Embed post`}
onPress={() => {
ax.metric('share:press:embed', {})
embedPostControl.open()
}}>
<Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText>
<Menu.ItemText>{l`Embed post`}</Menu.ItemText>
<Menu.ItemIcon icon={CodeBracketsIcon} position="right" />
</Menu.Item>
)}
@@ -142,7 +140,7 @@ let ShareMenuItems = ({
<Menu.Divider />
<Menu.Item
testID="postAtUriShareBtn"
label={_(msg`Copy post at:// URI`)}
label={l`Copy post at:// URI`}
onPress={onShareATURI}>
<Menu.ItemText>
<Trans>Copy post at:// URI</Trans>
@@ -151,7 +149,7 @@ let ShareMenuItems = ({
</Menu.Item>
<Menu.Item
testID="postAuthorDIDShareBtn"
label={_(msg`Copy author DID`)}
label={l`Copy author DID`}
onPress={onShareAuthorDID}>
<Menu.ItemText>
<Trans>Copy author DID</Trans>
@@ -161,7 +159,6 @@ let ShareMenuItems = ({
</>
)}
</Menu.Outer>
{canEmbed && (
<EmbedDialog
control={embedPostControl}
@@ -172,7 +169,6 @@ let ShareMenuItems = ({
timestamp={timestamp}
/>
)}
<SendViaChatDialog
control={sendViaChatControl}
onSelectChat={onSelectChatToShareTo}
+61 -11
View File
@@ -1,5 +1,6 @@
import {View} from 'react-native'
import {HITSLOP_20} from '#/lib/constants'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {atoms as a, useAlf, type ViewStyleProp} from '#/alf'
import {useNativeFontScale} from '#/alf/util/dimensions'
@@ -8,6 +9,7 @@ import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
import type * as bsky from '#/types/bsky'
import {BetaBadge, BetaBadgeButton, useIsBetaBadgeVisible} from './BetaBadge'
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
@@ -27,6 +29,22 @@ const botIconSizes: Record<Size, number> = {
xl: 23,
} as const
const betaIconSizes: Record<Size, number> = {
xs: 8,
sm: 8,
md: 8,
lg: 10,
xl: 12,
} as const
const betaBadgePadding: Record<Size, number> = {
xs: 1,
sm: 2,
md: 3,
lg: 4,
xl: 5,
} as const
export function ProfileBadges({
profile,
interactive = false,
@@ -41,13 +59,19 @@ export function ProfileBadges({
}) {
const shadowed = useProfileShadow(profile)
const verification = useSimpleVerificationState({profile})
const badgeVisibility = [
verification.showBadge,
useIsBetaBadgeVisible(profile),
isBotAccount(shadowed),
]
const badgeCount = badgeVisibility.filter(Boolean).length
const nativeScaleMultiplier = useNativeFontScale()
const {
fonts: {scaleMultiplier: alfScaleMultiplier},
} = useAlf()
// if nothing to show, don't render the container at all
if (!verification.showBadge && !isBotAccount(shadowed)) return null
if (badgeCount < 1) return null
const isOnTheSmallSide = size === 'xs' || size === 'sm'
@@ -57,31 +81,57 @@ export function ProfileBadges({
const verificationIconWidth = verificationIconSizes[size] * scaleMultiplier
const botIconWidth = botIconSizes[size] * scaleMultiplier
const betaIconWidth = betaIconSizes[size] * scaleMultiplier
const betaBadgeScaledPadding = betaBadgePadding[size] * scaleMultiplier
const gap = isOnTheSmallSide ? a.gap_2xs : a.gap_xs
const padding = gap.gap / 2
let visibleBadgeIndex = 0
const hitSlops = badgeVisibility.map(isVisible => {
if (!isVisible) return HITSLOP_20
const index = visibleBadgeIndex++
return {
...HITSLOP_20,
left: index === 0 ? HITSLOP_20.left : padding,
right: index === badgeCount - 1 ? HITSLOP_20.right : padding,
}
})
return (
<View
style={[
a.flex_row,
a.align_center,
isOnTheSmallSide ? a.gap_2xs : a.gap_xs,
style,
]}>
<View style={[a.flex_row, a.align_center, gap, style]}>
{interactive ? (
<>
<VerificationCheckButton
profile={shadowed}
width={verificationIconWidth}
hitSlop={hitSlops[0]}
/>
<BetaBadgeButton
profile={shadowed}
width={betaIconWidth}
padding={betaBadgeScaledPadding}
hitSlop={hitSlops[1]}
/>
<BotBadgeButton
profile={shadowed}
width={botIconWidth}
hitSlop={hitSlops[2]}
/>
<BotBadgeButton profile={shadowed} width={botIconWidth} />
</>
) : (
<>
{verification.showBadge && (
{verification.showBadge ? (
<VerificationCheck
verifier={verification.role === 'verifier'}
width={verificationIconWidth}
/>
)}
) : null}
<BetaBadge
profile={shadowed}
width={betaIconWidth}
padding={betaBadgeScaledPadding}
/>
<BotBadge profile={shadowed} width={botIconWidth} />
</>
)}
+6 -127
View File
@@ -1,143 +1,20 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {type AtUri} from '@atproto/api'
import {type AppBskyUnspeccedDefs, type AtUri} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
// import {makeProfileLink} from '#/lib/routes/links'
// import {feedUriToHref} from '#/lib/strings/url-helpers'
// import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
// import {CloseQuote_Filled_Stroke2_Corner0_Rounded as Quote} from '#/components/icons/Quote'
// import {UserAvatar} from '#/view/com/util/UserAvatar'
import {type TrendingTopic} from '#/state/queries/trending/useTrendingTopics'
import {atoms as a, native, useTheme, type ViewStyleProp} from '#/alf'
import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack'
import {native} from '#/alf'
import {Link as InternalLink, type LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
export function TrendingTopic({
topic: raw,
size,
style,
hovered,
}: {
topic: TrendingTopic
size?: 'large' | 'small'
hovered?: boolean
} & ViewStyleProp) {
const topic = useTopic(raw)
const isSmall = size === 'small'
const hasIcon = topic.type === 'starter-pack' && !isSmall
const iconSize = 20
return (
<View
style={[
a.flex_row,
a.align_center,
isSmall
? [
{
paddingVertical: 2,
paddingHorizontal: 4,
},
]
: [a.py_xs, a.px_sm],
hasIcon && {gap: 6},
style,
]}>
{hasIcon && topic.type === 'starter-pack' && (
<StarterPackIcon
gradient="sky"
width={iconSize}
style={{marginLeft: -3, marginVertical: -1}}
/>
)}
{/*
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
a.overflow_hidden,
{
width: iconSize,
height: iconSize,
},
]}>
{topic.type === 'tag' ? (
<Hashtag width={iconSize} />
) : topic.type === 'topic' ? (
<Quote width={iconSize - 2} />
) : topic.type === 'feed' ? (
<UserAvatar
type="user"
size={aviSize}
avatar=""
/>
) : (
<UserAvatar
type="user"
size={aviSize}
avatar=""
/>
)}
</View>
*/}
<Text
style={[
a.font_semi_bold,
a.leading_tight,
isSmall ? [a.text_sm] : [a.text_md, {paddingBottom: 1}],
hovered && {textDecorationLine: 'underline'},
]}
numberOfLines={1}>
{topic.displayName}
</Text>
</View>
)
}
export function TrendingTopicSkeleton({
size = 'large',
index = 0,
}: {
size?: 'large' | 'small'
index?: number
}) {
const t = useTheme()
const isSmall = size === 'small'
return (
<View
style={[
a.rounded_full,
a.border,
t.atoms.border_contrast_medium,
t.atoms.bg_contrast_25,
isSmall
? {
width: index % 2 === 0 ? 75 : 90,
height: 27,
}
: {
width: index % 2 === 0 ? 90 : 110,
height: 36,
},
]}
/>
)
}
export function TrendingTopicLink({
topic: raw,
children,
...rest
}: {
topic: TrendingTopic
topic: AppBskyUnspeccedDefs.TrendView
} & Omit<LinkProps, 'to' | 'label'>) {
const topic = useTopic(raw)
@@ -168,7 +45,9 @@ type ParsedTrendingTopic =
uri: AtUri
}
export function useTopic(raw: TrendingTopic): ParsedTrendingTopic {
export function useTopic(
raw: AppBskyUnspeccedDefs.TrendView,
): ParsedTrendingTopic {
const {_} = useLingui()
return useMemo(() => {
const {topic: displayName, link} = raw
+225 -12
View File
@@ -11,19 +11,33 @@ import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {MAX_GROUP_NAME_GRAPHEME_LENGTH} from '#/lib/constants'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session'
import {type ListMethods} from '#/view/com/util/List'
import {android, atoms as a, native, useTheme, web} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {canBeAddedToGroup, canBeMessaged} from '#/components/dms/util'
import {ChatProfileTabs} from '#/components/dms/ChatProfileTabs'
import {EmptyMemberList} from '#/components/dms/components/EmptyMemberList'
import {GroupChatProfileCard} from '#/components/dms/components/GroupChatProfileCard'
import {ProfileCardSkeleton} from '#/components/dms/components/ProfileCardSkeleton'
import {UserLabel} from '#/components/dms/components/UserLabel'
import {UserSearchInput} from '#/components/dms/components/UserSearchInput'
import {
canBeAddedToGroup,
canBeMessaged,
type ConvoWithDetails,
parseConvoView,
} from '#/components/dms/util'
import * as TextField from '#/components/forms/TextField'
import * as Toggle from '#/components/forms/Toggle'
import {
@@ -33,18 +47,13 @@ import {
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {ProfileBadges} from '#/components/ProfileBadges'
import * as ProfileCard from '#/components/ProfileCard'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
import {IS_NATIVE, IS_WEB} from '#/env'
import type * as bsky from '#/types/bsky'
import {ChatProfileTabs} from './ChatProfileTabs'
import {EmptyMemberList} from './components/EmptyMemberList'
import {GroupChatProfileCard} from './components/GroupChatProfileCard'
import {ProfileCardSkeleton} from './components/ProfileCardSkeleton'
import {UserLabel} from './components/UserLabel'
import {UserSearchInput} from './components/UserSearchInput'
type NewGroupChatItem = {
type: 'newGroupChat'
@@ -63,6 +72,12 @@ type ProfileItem = {
profile: bsky.profile.AnyProfileView
}
type ExistingChatItem = {
type: 'existingChat'
key: string
convo: ConvoWithDetails
}
type EmptyItem = {
type: 'empty'
key: string
@@ -83,6 +98,7 @@ type Item =
| NewGroupChatItem
| LabelItem
| ProfileItem
| ExistingChatItem
| EmptyItem
| PlaceholderItem
| ErrorItem
@@ -212,11 +228,17 @@ export function InitiateChatFlow({
onSelectChat,
onSelectGroupChat,
startInGroupChat = false,
showRecentConvos = false,
onSelectExistingChat,
sortByMessageDeclaration = false,
}: {
title: string
onSelectChat: (did: string) => void
onSelectGroupChat: (dids: string[], groupName: string) => void
startInGroupChat?: boolean
showRecentConvos?: boolean
onSelectExistingChat?: (convoId: string) => void
sortByMessageDeclaration?: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
@@ -230,6 +252,12 @@ export function InitiateChatFlow({
const inputRef = useRef<TextInput>(null)
const accountTooNewPromptControl = Dialog.useDialogControl()
const {data: convos} = useListConvosQuery({
enabled: showRecentConvos,
status: 'accepted',
lockStatus: 'unlocked',
})
const {data: chatStatus} = useChatActorStatusQuery()
const canCreateGroups = chatStatus?.canCreateGroups ?? true
const groupMemberLimit = chatStatus?.groupMemberLimit
@@ -281,6 +309,10 @@ export function InitiateChatFlow({
let _items: Item[] = []
const checker =
chatState === ChatState.NEW_GROUP_CHAT ? canBeAddedToGroup : canBeMessaged
const messageDeclarationRank = (item: Item) =>
item.type === 'profile' && checker(item.profile) ? 0 : 1
const compareByMessageDeclaration = (a: Item, b: Item) =>
messageDeclarationRank(a) - messageDeclarationRank(b)
if (isError) {
_items.push({
@@ -310,9 +342,9 @@ export function InitiateChatFlow({
})
}
_items = _items.sort(item => {
return item.type === 'profile' && checker(item.profile) ? -1 : 1
})
if (sortByMessageDeclaration) {
_items = _items.sort(compareByMessageDeclaration)
}
}
} else {
const placeholders: Item[] = Array(10)
@@ -322,7 +354,57 @@ export function InitiateChatFlow({
key: i + '',
}))
if (follows) {
if (
chatState === ChatState.NEW_CHAT &&
showRecentConvos &&
convos &&
follows
) {
const usedDids = new Set()
for (const page of convos.pages) {
for (const convoView of page.convos) {
const convo = parseConvoView(convoView, currentAccount?.did)
if (!convo) continue
if (convo.kind === 'group') {
_items.push({
type: 'existingChat',
key: convo.view.id,
convo,
})
} else {
if (convo.primaryMember.handle === 'missing.invalid') continue
if (usedDids.has(convo.primaryMember.did)) continue
usedDids.add(convo.primaryMember.did)
_items.push({
type: 'existingChat',
key: convo.view.id,
convo,
})
}
}
}
let followsItems: ProfileItem[] = []
for (const page of follows.pages) {
for (const profile of page.follows) {
if (usedDids.has(profile.did)) continue
if (!checker(profile)) continue
followsItems.push({
type: 'profile',
key: profile.did,
profile,
})
}
}
_items.push(...followsItems)
} else if (follows) {
for (const page of follows.pages) {
for (const profile of page.follows) {
if (!checker(profile)) continue
@@ -359,10 +441,19 @@ export function InitiateChatFlow({
_items.unshift({type: 'newGroupChat', key: 'newGroupChat'})
}
return _items
const profileDids = new Set<string>()
return _items.filter(item => {
if (item.type !== 'profile') return true
if (profileDids.has(item.profile.did)) return false
profileDids.add(item.profile.did)
return true
})
}, [
isError,
chatState,
convos,
searchText,
l,
groupChatProfiles,
@@ -370,6 +461,8 @@ export function InitiateChatFlow({
currentAccount?.did,
follows,
aa.flags.groupChatDisabled,
showRecentConvos,
sortByMessageDeclaration,
])
if (searchText && !isFetching && !items.length && !isError) {
@@ -429,6 +522,16 @@ export function InitiateChatFlow({
case 'label': {
return <UserLabel key={item.key} message={item.message} />
}
case 'existingChat': {
return showRecentConvos && onSelectExistingChat ? (
<ExistingChatCard
key={item.key}
convo={item.convo}
moderationOpts={moderationOpts!}
onPress={onSelectExistingChat}
/>
) : null
}
case 'profile': {
switch (chatState) {
case ChatState.NEW_CHAT:
@@ -474,6 +577,8 @@ export function InitiateChatFlow({
handlePressNewGroupChat,
moderationOpts,
onSelectChat,
onSelectExistingChat,
showRecentConvos,
],
)
@@ -845,6 +950,114 @@ function NewGroupChatButton({
)
}
function ExistingChatCard({
convo,
moderationOpts,
onPress,
}: {
convo: ConvoWithDetails
moderationOpts: ModerationOpts
onPress: (convoId: string) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const enabled =
convo.kind === 'group' ? convo.details.lockStatus === 'unlocked' : true
const name =
convo.kind === 'group'
? convo.details.name
: createSanitizedDisplayName(
convo.primaryMember,
true,
moderateProfile(convo.primaryMember, moderationOpts).ui(
'displayName',
),
)
const handleOnPress = useCallback(() => {
onPress(convo.view.id)
}, [onPress, convo.view.id])
return (
<Button
disabled={!enabled}
label={l`Select chat "${name}"`}
onPress={handleOnPress}>
{({hovered, pressed, focused}) => (
<View
style={[
a.flex_1,
a.py_sm,
a.px_lg,
!enabled
? {opacity: 0.5}
: pressed || focused || hovered
? t.atoms.bg_contrast_25
: t.atoms.bg,
]}>
<ProfileCard.Header>
{convo.kind === 'group' ? (
<AvatarBubbles profiles={convo.members} size={40} />
) : (
<ProfileCard.Avatar
profile={convo.primaryMember}
moderationOpts={moderationOpts}
disabledPreview
/>
)}
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center, a.max_w_full]}>
<Text
emoji
style={[
a.text_md,
a.font_semi_bold,
a.leading_snug,
a.self_start,
a.flex_shrink,
]}
numberOfLines={1}>
{name}
</Text>
{convo.kind === 'direct' && (
<ProfileBadges
profile={convo.primaryMember}
size="md"
style={[a.pl_xs]}
/>
)}
</View>
{convo.kind === 'direct' ? (
<ProfileCard.Handle profile={convo.primaryMember} />
) : (
<>
{enabled ? (
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={2}>
<Plural
value={convo.details.memberCount}
one="# member"
other="# members"
/>
</Text>
) : (
<Text
style={[a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}>
<Trans>Group is locked</Trans>
</Text>
)}
</>
)}
</View>
</ProfileCard.Header>
</View>
)}
</Button>
)
}
function DefaultProfileCard({
profile,
moderationOpts,
+2 -1
View File
@@ -89,7 +89,7 @@ export function NewChat({
},
onError: error => {
logger.error('Failed to create groupchat', {safeMessage: error})
let errorMessage = l`An issue occurred creating the group chat, please try again.`
let errorMessage = l`An issue occurred starting the group chat, please try again.`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (
@@ -184,6 +184,7 @@ export function NewChat({
title={l`New chat`}
onSelectChat={onCreateChat}
onSelectGroupChat={onCreateGroupChat}
sortByMessageDeclaration
startInGroupChat={startInGroupChat}
/>
) : (
+114 -10
View File
@@ -1,11 +1,17 @@
import {useCallback} from 'react'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useCallback, useState} from 'react'
import {
ChatBskyConvoGetConvoForMembers,
ChatBskyGroupCreateGroup,
} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useCreateGroupChat} from '#/state/queries/messages/create-group-chat'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
import {InitiateChatFlow} from '#/components/dms/InitiateChatFlow'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
@@ -16,26 +22,39 @@ export function SendViaChatDialog({
control: Dialog.DialogControlProps
onSelectChat: (chatId: string) => void
}) {
const [flowKey, setFlowKey] = useState(0)
const onClose = useCallback(() => setFlowKey(key => key + 1), [])
return (
<Dialog.Outer
control={control}
testID="sendViaChatChatDialog"
nativeOptions={{fullHeight: true}}>
nativeOptions={{fullHeight: true}}
onClose={onClose}>
<Dialog.Handle />
<SendViaChatDialogInner control={control} onSelectChat={onSelectChat} />
<SendViaChatDialogInner
control={control}
flowKey={flowKey}
onSelectChat={onSelectChat}
/>
</Dialog.Outer>
)
}
function SendViaChatDialogInner({
control,
flowKey,
onSelectChat,
}: {
control: Dialog.DialogControlProps
flowKey: number
onSelectChat: (chatId: string) => void
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const ax = useAnalytics()
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
const {mutate: createChat} = useGetConvoForMembers({
onSuccess: data => {
onSelectChat(data.convo.id)
@@ -46,8 +65,74 @@ function SendViaChatDialogInner({
ax.metric('chat:open', {logContext: 'SendViaChatDialog'})
},
onError: error => {
logger.error('Failed to share post to chat', {message: error})
Toast.show(_(msg`An issue occurred while trying to open the chat`), {
logger.error('Failed to share post to chat', {safeMessage: error})
let errorMessage = l`An issue occurred starting the chat, please try again.`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (
error instanceof ChatBskyConvoGetConvoForMembers.AccountSuspendedError
) {
errorMessage = l`Suspended accounts cannot participate in chat.`
} else if (
error instanceof ChatBskyConvoGetConvoForMembers.BlockedActorError
) {
errorMessage = l`This user has blocked you and cannot be messaged.`
} else if (
error instanceof ChatBskyConvoGetConvoForMembers.MessagesDisabledError
) {
errorMessage = l`This user has disabled chat and cannot be messaged.`
} else if (
error instanceof
ChatBskyConvoGetConvoForMembers.NotFollowedBySenderError
) {
errorMessage = l`Chat recipient is not followed by the sender.`
} else if (
error instanceof ChatBskyConvoGetConvoForMembers.RecipientNotFoundError
) {
errorMessage = l`Unable to find the selected recipient.`
}
Toast.show(errorMessage, {
type: 'error',
})
},
})
const {mutate: createGroupChat} = useCreateGroupChat({
onSuccess: data => {
onSelectChat(data.convo.id)
ax.metric('groupchat:create', {logContext: 'SendViaChatDialog'})
},
onError: error => {
logger.error('Failed to share post to group chat', {safeMessage: error})
let errorMessage = l`An issue occurred starting the group chat, please try again.`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (
error instanceof ChatBskyGroupCreateGroup.AccountSuspendedError
) {
errorMessage = l`Suspended accounts cannot participate in a group chat.`
} else if (error instanceof ChatBskyGroupCreateGroup.BlockedActorError) {
errorMessage = l`One of the selected recipients has blocked you and cannot be messaged.`
} else if (
error instanceof
ChatBskyGroupCreateGroup.NewAccountCannotCreateGroupError
) {
errorMessage = l`You cannot create a group chat yet.`
} else if (
error instanceof ChatBskyGroupCreateGroup.NotFollowedBySenderError
) {
errorMessage = l`A selected recipient is not followed by the sender.`
} else if (
error instanceof ChatBskyGroupCreateGroup.RecipientNotFoundError
) {
errorMessage = l`Unable to find a selected recipient.`
} else if (
error instanceof ChatBskyGroupCreateGroup.UserForbidsGroupsError
) {
errorMessage = l`One of the selected recipients does not allow group chats.`
}
Toast.show(errorMessage, {
type: 'error',
})
},
@@ -67,9 +152,28 @@ function SendViaChatDialogInner({
[control, createChat],
)
return (
const onCreateGroupChat = useCallback(
(members: string[], name: string) => {
control.close(() => {
createGroupChat({members, name})
})
},
[control, createGroupChat],
)
return isGroupChatEnabled ? (
<InitiateChatFlow
key={flowKey}
title={l`Send post to...`}
onSelectChat={onCreateChat}
onSelectExistingChat={onSelectExistingChat}
onSelectGroupChat={onCreateGroupChat}
showRecentConvos
sortByMessageDeclaration
/>
) : (
<SearchablePeopleList
title={_(msg`Send post to...`)}
title={l`Send post to...`}
onSelectChat={chat => {
if (chat.kind === 'user') {
onCreateChat(chat.did)
+2 -4
View File
@@ -5,7 +5,7 @@ import {type AppBskyEmbedImages} from '@atproto/api'
import {atoms as a, useBreakpoints} from '#/alf'
import {type Dimensions} from '#/components/Lightbox/types'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {type PostEmbedViewContext} from '#/components/Post/Embed/types'
import {GalleryItem} from './ImageLayoutGridItem'
interface ImageLayoutGridProps {
@@ -28,9 +28,7 @@ export function ImageLayoutGrid({
...props
}: ImageLayoutGridProps) {
const {gtMobile} = useBreakpoints()
const isWithinQuote =
isWithinQuoteProp ??
props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const isWithinQuote = isWithinQuoteProp
const gap = isWithinQuote ? (gtMobile ? a.gap_xs : a.gap_2xs) : a.gap_xs
return (
@@ -0,0 +1,280 @@
import {useMemo} from 'react'
import {Pressable, View} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
import {type AppBskyUnspeccedDefs, moderateProfile} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useTrendingSettings} from '#/state/preferences/trending'
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
import {useTrendingConfig} from '#/state/service-config'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {formatCount} from '#/view/com/util/numeric/format'
import {
atoms as a,
useGutters,
useLayoutBreakpoints,
useTheme,
type ViewStyleProp,
} from '#/alf'
import {alpha} from '#/alf/utils'
import {AvatarStack} from '#/components/AvatarStack'
import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending'
import {Link} from '#/components/Link'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
const TOPIC_COUNT = 3
export function FeedTrendingTopicsInterstitial() {
const {enabled} = useTrendingConfig()
const {trendingDisabled} = useTrendingSettings()
const {rightNavVisible} = useLayoutBreakpoints()
return enabled && !trendingDisabled && !rightNavVisible ? <Inner /> : null
}
function Inner() {
const t = useTheme()
const {t: l} = useLingui()
const gutters = useGutters([0, 'base'])
const ax = useAnalytics()
const {
data: trending,
error,
isLoading,
isRefetching,
} = useGetTrendsQuery({limit: TOPIC_COUNT})
const noTopics = !isLoading && !error && !trending?.trends?.length
const shadowColor = alpha(t.palette.primary_100, 0.5)
const gradient = {
values: [
[0, t.atoms.bg.backgroundColor],
[0.1, t.palette.primary_25],
[0.9, t.palette.primary_25],
[1, t.atoms.bg.backgroundColor],
],
hover_value: t.palette.white,
}
if (error || noTopics) {
return null
}
return (
<View
style={[
gutters,
a.pt_lg,
a.pb_xl,
a.gap_sm,
a.border_t,
t.atoms.border_contrast_low,
]}>
<LinearGradient
colors={gradient.values.map(c => c[1]) as [string, string, ...string[]]}
locations={
gradient.values.map(c => c[0]) as [number, number, ...number[]]
}
style={[a.absolute, a.inset_0]}
/>
<View
style={[
a.relative,
a.z_20,
a.px_xs,
a.flex_row,
a.align_center,
a.justify_between,
a.gap_sm,
]}>
<View style={[a.flex_row, a.align_center, a.justify_between, a.gap_xs]}>
<TrendingIcon width={18} />
<Text
style={[a.text_md, a.font_medium, a.leading_snug]}
numberOfLines={1}>
<Trans>Trending</Trans>
</Text>
</View>
<Link label={l`See more trending topics`} to="/search">
<Text
style={[
a.text_sm,
a.font_medium,
a.leading_snug,
t.atoms.text_contrast_high,
]}
numberOfLines={1}>
<Trans>See more</Trans>
</Text>
</Link>
</View>
<View
style={[
a.relative,
a.z_10,
a.border,
a.rounded_xl,
t.atoms.bg,
{
borderColor: t.palette.primary_100,
boxShadow: `0 0 16px 0 ${shadowColor}`,
elevation: 8,
shadowColor: shadowColor,
shadowOffset: {width: 0, height: 0},
shadowOpacity: 1,
shadowRadius: 16,
},
]}>
{isLoading || isRefetching
? Array.from({length: TOPIC_COUNT}).map((_, i) => (
<TrendingTopicRowSkeleton key={i} rank={i + 1} />
))
: trending?.trends?.map((trend, index) => (
<TrendRow
key={trend.link}
trend={trend}
rank={index + 1}
onPress={() => {
ax.metric('trendingTopic:click', {context: 'interstitial'})
}}
/>
))}
</View>
</View>
)
}
function TrendRow({
trend,
rank,
onPress,
}: ViewStyleProp & {
trend: AppBskyUnspeccedDefs.TrendView
rank: number
children?: React.ReactNode
onPress?: () => void
}) {
const t = useTheme()
const {t: l, i18n} = useLingui()
const actors = useModerateTrendingActors(trend.actors)
return (
<Link
testID={trend.link}
label={l`Browse topic ${trend.displayName}`}
to={trend.link}
onPress={onPress}
style={[
rank < TOPIC_COUNT && a.border_b,
{
borderColor: t.palette.primary_100,
},
]}
PressableComponent={Pressable}>
{({hovered, pressed}) => (
<>
<SubtleHover hover={hovered || pressed} native />
<View
style={[
a.w_full,
a.flex_row,
a.flex_row,
{
gap: 6,
padding: 14,
paddingLeft: 16,
},
]}>
<Text
style={[
a.text_md,
a.font_semi_bold,
t.atoms.text_contrast_low,
{
fontVariant: ['tabular-nums'],
},
]}>
<Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'>
{rank}.
</Trans>
</Text>
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.text_md, a.font_medium]} numberOfLines={1}>
{trend.displayName}
</Text>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
{actors.length > 0 ? (
<AvatarStack size={24} profiles={actors} />
) : null}
<Text
style={[a.text_sm, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{trend.postCount >= 1000 ? (
<Trans comment="Over 1,000 posts">1K+ posts</Trans>
) : (
<Trans comment="'{postCount} {posts}', e.g., '1.2K posts'">
{formatCount(i18n, trend.postCount)}{' '}
{plural(trend.postCount, {one: 'post', other: 'posts'})}
</Trans>
)}
</Text>
</View>
</View>
</View>
</>
)}
</Link>
)
}
function TrendingTopicRowSkeleton({rank}: {rank: number}) {
const t = useTheme()
return (
<View
style={[
a.w_full,
a.flex_row,
a.px_lg,
a.py_lg,
a.flex_row,
rank < TOPIC_COUNT && a.border_b,
t.atoms.border_contrast_low,
{
gap: 6,
},
]}>
<LoadingPlaceholder width={17} height={17} style={[a.rounded_full]} />
<View style={[a.flex_1, a.gap_xs]}>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<LoadingPlaceholder width={70} height={17} />
<LoadingPlaceholder width={40} height={17} />
<LoadingPlaceholder width={60} height={17} />
</View>
<LoadingPlaceholder width={24} height={24} style={[a.rounded_full]} />
</View>
</View>
)
}
function useModerateTrendingActors(
actors: AppBskyUnspeccedDefs.TrendView['actors'],
) {
const moderationOpts = useModerationOpts()
return useMemo(() => {
if (!moderationOpts) return []
return actors
.filter(actor => {
const decision = moderateProfile(actor, moderationOpts)
return !decision.ui('avatar').filter && !decision.ui('avatar').blur
})
.slice(0, 3)
}, [actors, moderationOpts])
}
+15 -5
View File
@@ -7,7 +7,7 @@ import {
useTrendingSettings,
useTrendingSettingsApi,
} from '#/state/preferences/trending'
import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics'
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
import {useTrendingConfig} from '#/state/service-config'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
@@ -20,6 +20,8 @@ import {TrendingTopicLink} from '#/components/TrendingTopics'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
const TRENDING_LIMIT = 14
export function TrendingInterstitial() {
const {enabled} = useTrendingConfig()
const {trendingDisabled} = useTrendingSettings()
@@ -33,8 +35,15 @@ export function Inner() {
const gutters = useGutters([0, 'base', 0, 'base'])
const trendingPrompt = Prompt.usePromptControl()
const {setTrendingDisabled} = useTrendingSettingsApi()
const {data: trending, error, isLoading} = useTrendingTopics()
const noTopics = !isLoading && !error && !trending?.topics?.length
const {
data: trending,
error,
isLoading,
} = useGetTrendsQuery({
limit: TRENDING_LIMIT,
refetchOnWindowFocus: true,
})
const noTopics = !isLoading && !error && !trending?.trends?.length
const onConfirmHide = useCallback(() => {
ax.metric('trendingTopics:hide', {context: 'interstitial'})
@@ -88,15 +97,16 @@ export function Inner() {
{' '}
</Text>
</View>
) : !trending?.topics ? null : (
) : !trending?.trends ? null : (
<>
{trending.topics.map(topic => (
{trending.trends.map(topic => (
<TrendingTopicLink
key={topic.link}
topic={topic}
onPress={() => {
ax.metric('trendingTopic:click', {
context: 'interstitial',
recId: trending.recId,
})
}}>
<View style={[a.py_lg]}>
+2 -5
View File
@@ -181,6 +181,7 @@ function BlockDialogInner({
const footer = (
<View style={[a.w_full, a.gap_sm, a.justify_end]}>
<Button
disabled={isLoading}
color={profile.viewer?.blocking ? undefined : 'negative'}
size="large"
label={profile.viewer?.blocking ? l`Unblock` : l`Block`}
@@ -192,6 +193,7 @@ function BlockDialogInner({
<Trans>Block</Trans>
)}
</ButtonText>
{isLoading ? <ButtonIcon icon={Loader} /> : null}
</Button>
<Button
color="secondary"
@@ -211,11 +213,6 @@ function BlockDialogInner({
label={profile.viewer?.blocking ? l`Unblock` : l`Block`}
style={[web([{maxWidth: 420}])]}>
{listHeader}
{isLoading ? (
<View style={[a.pb_2xl, a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
) : null}
{footer}
</Dialog.ScrollableInner>
)
+10 -17
View File
@@ -6,9 +6,7 @@ import {
type ViewStyle,
} from 'react-native'
import {type ModerationUI} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {
ADULT_CONTENT_LABELS,
@@ -78,7 +76,7 @@ function ContentHiderActive({
children?: React.ReactNode
}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const {gtMobile} = useBreakpoints()
const [override, setOverride] = useState(false)
const control = useModerationDetailsDialogControl()
@@ -97,7 +95,7 @@ function ContentHiderActive({
(blur.type === 'label' && blur.source.type !== 'user')
) {
if (desc.isSubjectAccount) {
return _(msg`${desc.name} (Account)`)
return l`${desc.name} (Account)`
} else {
return desc.name
}
@@ -128,7 +126,7 @@ function ContentHiderActive({
const def = cause.labelDef || getDefinition(labelDefs, cause.label)
if (def.identifier === 'porn' || def.identifier === 'sexual') {
return _(msg`Adult Content`)
return l`Adult Content`
}
return getLabelStrings(i18n.locale, globalLabelStrings, def).name
})
@@ -138,7 +136,7 @@ function ContentHiderActive({
}
return [...new Set(selfBlurNames)].join(', ')
}, [
_,
l,
modui.blurs,
blur,
desc.name,
@@ -151,7 +149,6 @@ function ContentHiderActive({
return (
<View testID={testID} style={[a.overflow_hidden, style]}>
<ModerationDetailsDialog control={control} modcause={blur} />
<Button
onPress={e => {
e.preventDefault()
@@ -166,10 +163,10 @@ function ContentHiderActive({
label={desc.name}
accessibilityHint={
modui.noOverride
? _(msg`Learn more about the moderation applied to this content`)
? l`Learn more about the moderation applied to this content`
: override
? _(msg`Hides the content`)
: _(msg`Shows the content`)
? l`Hides the content`
: l`Shows the content`
}>
{state => (
<View
@@ -223,7 +220,6 @@ function ContentHiderActive({
</View>
)}
</Button>
{desc.source && blur.type === 'label' && !override && (
<Button
onPress={e => {
@@ -231,9 +227,7 @@ function ContentHiderActive({
e.stopPropagation()
control.open()
}}
label={_(
msg`Learn more about the moderation applied to this content`,
)}
label={l`Learn more about the moderation applied to this content`}
style={[a.pt_sm]}>
{state => (
<Text
@@ -252,7 +246,7 @@ function ContentHiderActive({
)}{' '}
<Text
style={[
{color: t.palette.primary_500},
t.atoms.text_link,
a.text_sm,
state.hovered && [web({textDecoration: 'underline'})],
]}>
@@ -262,7 +256,6 @@ function ContentHiderActive({
)}
</Button>
)}
{override && <View style={childContainerStyle}>{children}</View>}
</View>
)
+9 -1
View File
@@ -54,7 +54,15 @@ export function PostAlerts({
const isOwnPost = !!post && post.author.did === currentAccount?.did
const allLabels: ComAtprotoLabelDefs.Label[] =
isOwnPost && view === 'expanded'
? [...(post.labels ?? []), ...(post.author.labels ?? [])]
? [
...(post.labels ?? []),
/*
* Account labels appear on Profile. We don't show them here unless the
* user's mod settings are configured such that the labels land in the
* modui handling.
*/
// ...(post.author.labels ?? [])
]
: []
/*
* Labels that the moderation system already surfaces in this context -
+6 -10
View File
@@ -6,9 +6,7 @@ import {
type ViewStyle,
} from 'react-native'
import {type ModerationUI} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
@@ -38,7 +36,7 @@ export function ScreenHider({
containerStyle?: StyleProp<ViewStyle>
}>) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const [override, setOverride] = useState(false)
const navigation = useNavigation<NavigationProp>()
const {isMobile} = useWebMediaQueries()
@@ -131,15 +129,13 @@ export function ScreenHider({
control.open()
}}
accessibilityRole="button"
accessibilityLabel={_(msg`Learn more about this warning`)}
accessibilityLabel={l`Learn more about this warning`}
accessibilityHint="">
<Text
style={[
a.text_lg,
a.leading_snug,
{
color: t.palette.primary_500,
},
t.atoms.text_link,
web({
cursor: 'pointer',
}),
@@ -158,7 +154,7 @@ export function ScreenHider({
color="primary"
size="large"
style={[a.rounded_full]}
label={_(msg`Go back`)}
label={l`Go back`}
onPress={() => {
if (navigation.canGoBack()) {
navigation.goBack()
@@ -176,7 +172,7 @@ export function ScreenHider({
color="secondary"
size="large"
style={[a.rounded_full]}
label={_(msg`Show anyway`)}
label={l`Show anyway`}
onPress={() => setOverride(v => !v)}>
<ButtonText>
<Trans>Show anyway</Trans>
@@ -1,6 +1,5 @@
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {type Insets, View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {type Shadow} from '#/state/cache/types'
import {atoms as a, useTheme} from '#/alf'
@@ -52,16 +51,25 @@ export function shouldShowVerificationCheckButton(
export function VerificationCheckButton({
profile,
width,
hitSlop,
}: {
profile: Shadow<bsky.profile.AnyProfileView>
width: number
hitSlop: Insets
}) {
const state = useFullVerificationState({
profile,
})
if (shouldShowVerificationCheckButton(state)) {
return <Badge profile={profile} verificationState={state} width={width} />
return (
<Badge
profile={profile}
verificationState={state}
width={width}
hitSlop={hitSlop}
/>
)
}
return null
@@ -71,14 +79,16 @@ function Badge({
profile,
verificationState: state,
width,
hitSlop,
}: {
profile: Shadow<bsky.profile.AnyProfileView>
verificationState: FullVerificationState
width: number
hitSlop: Insets
}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const {t: l} = useLingui()
const verificationsDialogControl = useDialogControl()
const verifierDialogControl = useDialogControl()
@@ -89,10 +99,10 @@ function Badge({
<Button
label={
state.profile.isViewer
? _(msg`View your verifications`)
: _(msg`View this user's verifications`)
? l`View your verifications`
: l`View this user's verifications`
}
hitSlop={20}
hitSlop={hitSlop}
onPress={evt => {
evt.preventDefault()
ax.metric('verification:badge:click', {})
@@ -132,13 +142,11 @@ function Badge({
</View>
)}
</Button>
<VerificationsDialog
control={verificationsDialogControl}
profile={profile}
verificationState={state}
/>
<VerifierDialog
control={verifierDialogControl}
profile={profile}
@@ -3,11 +3,9 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useTrendingSettings} from '#/state/preferences/trending'
import {atoms as a, useLayoutBreakpoints} from '#/alf'
import {Button} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times'
import {TrendingInterstitial} from '#/components/interstitials/Trending'
import * as Toast from '#/components/Toast'
import {LiveEventFeedCardWide} from '#/features/liveEvents/components/LiveEventFeedCardWide'
import {useUserPreferencedLiveEvents} from '#/features/liveEvents/context'
@@ -17,16 +15,10 @@ import {type LiveEventFeed} from '#/features/liveEvents/types'
export function DiscoverFeedLiveEventFeedsAndTrendingBanner() {
const events = useUserPreferencedLiveEvents()
const {rightNavVisible} = useLayoutBreakpoints()
const {trendingDisabled} = useTrendingSettings()
if (!events.feeds.length) {
if (!rightNavVisible && !trendingDisabled) {
// only show trending on mobile when live event banner is not shown
return <TrendingInterstitial />
} else {
// no feed, no trending
return null
}
// no feed
return null
}
// On desktop, we show in the sidebar
+3
View File
@@ -146,6 +146,9 @@ export const BSKY_FEED_OWNER_DIDS = [
'did:plc:q6gjnaw2blty4crticxkmujt',
]
export const TRENDING_DID = 'did:plc:qrz3lhbyuxbeilrc6nekdqme'
export const TRENDING_HANDLE = 'trending.bsky.app'
export const DISCOVER_FEED_URI =
'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot'
export const VIDEO_FEED_URI =
+44
View File
@@ -1,3 +1,4 @@
import {Asset} from 'expo-asset'
import {
documentDirectory,
getInfoAsync,
@@ -9,10 +10,15 @@ import ExpoImageCropTool, {
} from '@bsky.app/expo-image-crop-tool'
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
import {IS_ANDROID} from '#/env'
import {compressIfNeeded} from './manip'
import {type PickerImage} from './picker.shared'
async function getFile() {
if (IS_ANDROID) {
return await getAndroidFile()
}
const imagesDir = documentDirectory!
.split('/')
.slice(0, -6)
@@ -41,6 +47,44 @@ async function getFile() {
)
}
/*
* The Android emulator can't reach the iOS simulator's sample photo library,
* so we load a jpg bundled with the app instead. It is bundled via require()
* (resolved by Metro), so it survives `pm clear`, which Maestro's clearState
* runs at the start of every flow. An adb-seeded file in app-scoped external
* storage does not survive: pm clear wipes that directory each flow, so the
* seeded file is gone before the picker mock ever reads it.
*/
async function getAndroidFile() {
const asset = Asset.fromModule(
require('../../../assets/images/welcome-modal-bg.jpg'),
)
await asset.downloadAsync()
const path = asset.localUri!
const fileInfo = await getInfoAsync(path)
if (!fileInfo.exists) {
throw new Error('Failed to get file info')
}
/*
* Dimensions of the bundled asset (assets/images/welcome-modal-bg.jpg). Only
* used for downstream aspect-ratio display; the actual bytes are read from
* disk by compressIfNeeded.
*/
return await compressIfNeeded(
{
path,
mime: 'image/jpeg',
size: fileInfo.size,
width: 1432,
height: 1025,
},
IMAGE_SIZE_CONFIG_2K_1MB,
)
}
export async function openPicker(): Promise<PickerImage[]> {
return [await getFile()]
}
+1
View File
@@ -70,6 +70,7 @@ export type CommonNavigatorParams = {
ActivityPrivacySettings: undefined
ContentAndMediaSettings: undefined
NotificationSettings: undefined
ActivityNotificationSettings: undefined
InterestsSettings: undefined
AboutSettings: undefined
AppIconSettings: undefined
File diff suppressed because one or more lines are too long
+1
View File
@@ -61,6 +61,7 @@ export const router = new Router<AllNavigatableRoutes>({
AboutSettings: '/settings/about',
AppIconSettings: '/settings/app-icon',
NotificationSettings: '/settings/notifications',
ActivityNotificationSettings: '/settings/notifications/activity',
FindContactsSettings: '/settings/find-contacts',
// support
Support: '/support',
@@ -370,7 +370,7 @@ function SuggestedProfileCard({
category: string | null
onSeen: (did: string, position: number) => void
recSource?: 'Search'
recId?: number | string
recId?: string
}) {
const t = useTheme()
const ax = useAnalytics()
+59 -92
View File
@@ -1,15 +1,13 @@
import {View} from 'react-native'
import {type AppBskyFeedDefs, AtUri, moderateProfile} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {enforceLen} from '#/lib/strings/helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useLikedBySampleQuery} from '#/state/queries/post-liked-by'
import {useSession} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack'
import {InlineLinkText, Link} from '#/components/Link'
import {useFormatPostStatCount} from '#/components/PostControls/util'
@@ -18,27 +16,56 @@ import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
const AVI_SIZE = 20
const MAX_NAME_LENGTH = 16
/**
* The likes stat for the expanded anchor post. When the viewer follows some
* of the post's recent likers, renders social proof - a face pile plus
* "Liked by A, B, and N others" - in place of the plain "N likes" text,
* which it falls back to otherwise.
*
* Known likers are sourced client-side from a single `getLikes` request (100
* likes, the API max per page), so they are a sample of the most recent
* likers, not an exhaustive list. Only the faces and names are affected by
* sampling - the "N others" count is derived from the post's total like
* count.
* The plain "N likes" stat for the expanded anchor post, linking to the likes
* list. Renders nothing when the post has no likes.
*/
export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {t: l} = useLingui()
const formatPostStatCount = useFormatPostStatCount()
const ax = useAnalytics()
const likeCount = post.likeCount ?? 0
if (likeCount === 0) return null
const urip = new AtUri(post.uri)
const likesHref = makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
return (
<Link
to={likesHref}
label={l`Likes on this post`}
onPress={() => ax.metric('post:likedBy:click', {})}>
<Text
testID="likeCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(likeCount)}
</Text>{' '}
<Plural value={likeCount} one="like" other="likes" />
</Trans>
</Text>
</Link>
)
}
/**
* Social proof for the expanded anchor post. When the viewer follows some of
* the post's recent likers, renders a face pile plus "Liked by A and B" on
* its own row below the interaction stats line. Renders nothing otherwise.
*
* Known likers are sourced client-side from a single `getLikes` request (100
* likes, the API max per page), so they are a sample of the most recent
* likers, not an exhaustive list.
*/
export function KnownLikers({post}: {post: AppBskyFeedDefs.PostView}) {
const t = useTheme()
const {t: l} = useLingui()
const {hasSession, currentAccount} = useSession()
const moderationOpts = useModerationOpts()
const formatPostStatCount = useFormatPostStatCount()
const ax = useAnalytics()
const likeCount = post.likeCount ?? 0
@@ -78,67 +105,34 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
knownLikersAndModeration.length > 0 &&
ax.features.enabled(ax.features.PostThreadKnownLikersEnable)
if (!showKnownLikers) {
return (
<Link
to={likesHref}
label={l`Likes on this post`}
onPress={onPressLikedBy}>
<Text
testID="likeCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(likeCount)}
</Text>{' '}
<Plural value={likeCount} one="like" other="likes" />
</Trans>
</Text>
</Link>
)
}
if (!showKnownLikers) return null
const aviStackProfiles = knownLikersAndModeration
.slice(0, 3)
.map(({actor}) => actor)
const maxNames = gtMobile ? 2 : 1
const names = knownLikersAndModeration
.slice(0, maxNames)
.slice(0, 2)
.map(({actor, moderation}) => {
return {
did: actor.did,
href: makeProfileLink(actor),
displayName: enforceLen(
sanitizeDisplayName(
actor.displayName || actor.handle,
moderation.ui('displayName'),
),
MAX_NAME_LENGTH,
true,
displayName: sanitizeDisplayName(
actor.displayName || actor.handle,
moderation.ui('displayName'),
),
}
})
const others = likeCount - names.length
/*
* The row link's a11y label mirrors the visible sentence so screen readers
* announce the social proof.
*/
const othersLabel = plural(others, {
one: `${formatPostStatCount(others)} other`,
other: `${formatPostStatCount(others)} others`,
})
const rowLabel =
names.length >= 2
? others > 0
? l`${names[0].displayName}, ${names[1].displayName}, and ${othersLabel} like this`
: l`${names[0].displayName} and ${names[1].displayName} like this`
: others > 0
? l`${names[0].displayName} and ${othersLabel} like this`
: l`${names[0].displayName} likes this`
? l`Liked by ${names[0].displayName} and ${names[1].displayName}`
: l`Liked by ${names[0].displayName}`
const textStyle = [a.text_md, t.atoms.text_contrast_medium]
const nameStyle = [a.text_md, a.font_semi_bold, t.atoms.text]
const textStyle = [a.text_sm, t.atoms.text_contrast_medium]
const nameStyle = [a.text_sm, a.font_semi_bold, t.atoms.text]
/*
* Nested inside the row link, but the deepest link claims the press, so
@@ -160,10 +154,8 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
return (
/*
* The full-width wrapper keeps the social proof on its own line within
* the wrapping stats row, rather than wrapping mid-row and orphaning
* whichever count stat comes last. The link itself hugs its content so
* the empty space to the right of the text is not pressable.
* The full-width wrapper forces the social proof onto its own line below
* the count stats within the wrapping stats row.
*/
<View style={[a.w_full, a.flex_row]}>
<Link
@@ -172,39 +164,14 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
style={[a.flex_row, a.align_center, a.gap_sm, a.flex_shrink]}
onPress={onPressLikedBy}>
<AvatarStack profiles={aviStackProfiles} size={AVI_SIZE} />
<Text
testID="knownLikersStat"
numberOfLines={1}
style={[a.flex_shrink, textStyle]}>
<Text testID="knownLikersStat" style={[a.flex_shrink, textStyle]}>
{names.length >= 2 ? (
others > 0 ? (
<Trans comment="Social proof on the likes stat; the bolded names are people the viewer follows who liked the post, and the count is the remaining number of likes">
{nameLink(names[0])}, {nameLink(names[1])}, and{' '}
<Plural
value={others}
one={`${formatPostStatCount(others)} other`}
other={`${formatPostStatCount(others)} others`}
/>{' '}
like this
</Trans>
) : (
<Trans comment="Social proof on the likes stat; the bolded names are people the viewer follows who liked the post and are its only likes">
{nameLink(names[0])} and {nameLink(names[1])} like this
</Trans>
)
) : others > 0 ? (
<Trans comment="Social proof on the likes stat; the bolded name is a person the viewer follows who liked the post, and the count is the remaining number of likes">
{nameLink(names[0])} and{' '}
<Plural
value={others}
one={`${formatPostStatCount(others)} other`}
other={`${formatPostStatCount(others)} others`}
/>{' '}
like this
<Trans comment="Social proof below the post stats; the bolded names are people the viewer follows who liked the post">
Liked by {nameLink(names[0])} and {nameLink(names[1])}
</Trans>
) : (
<Trans comment="Social proof on the likes stat; the bolded name is a person the viewer follows who liked the post and is its only like">
{nameLink(names[0])} likes this
<Trans comment="Social proof below the post stats; the bolded name is a person the viewer follows who liked the post">
Liked by {nameLink(names[0])}
</Trans>
)}
</Text>
@@ -28,7 +28,7 @@ import {type OnPostSuccessData} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {type PostSource} from '#/state/unstable-post-source'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {LikesStat} from '#/screens/PostThread/components/LikesStat'
import {KnownLikers, LikesStat} from '#/screens/PostThread/components/LikesStat'
import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton'
import {
LINEAR_AVI_WIDTH,
@@ -440,7 +440,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
a.py_md,
t.atoms.border_contrast_low,
]}>
<LikesStat post={post} />
{post.repostCount != null && post.repostCount !== 0 ? (
<Link to={repostsHref} label={l`Reposts of this post`}>
<Text
@@ -481,6 +480,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
</Text>
</Link>
) : null}
<LikesStat post={post} />
{post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
<Text
testID="bookmarkCount-expanded"
@@ -497,6 +497,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
</Trans>
</Text>
) : null}
<KnownLikers post={post} />
</View>
) : null}
<View
+15 -14
View File
@@ -1,13 +1,12 @@
import {useCallback, useEffect, useMemo, useState} from 'react'
import {useAnimatedRef} from 'react-native-reanimated'
import {AppBskyFeedDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {useIsFocused} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useQueryClient} from '@tanstack/react-query'
import {VIDEO_FEED_URIS} from '#/lib/constants'
import {TRENDING_DID, TRENDING_HANDLE, VIDEO_FEED_URIS} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
import {type CommonNavigatorParams} from '#/lib/routes/types'
@@ -52,7 +51,7 @@ export function ProfileFeedScreen(props: Props) {
const feedParams: FeedParams | undefined = props.route.params.feedCacheKey
? {feedCacheKey: props.route.params.feedCacheKey}
: undefined
const {_} = useLingui()
const {t: l} = useLingui()
const uri = useMemo(
() => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey),
@@ -70,7 +69,7 @@ export function ProfileFeedScreen(props: Props) {
<Layout.Screen testID="profileFeedScreenError">
<ErrorScreen
showHeader
title={_(msg`Could not load feed`)}
title={l`Could not load feed`}
message={cleanError(error)}
onPressTryAgain={() => void refetch()}
/>
@@ -131,7 +130,7 @@ export function ProfileFeedScreenInner({
feedInfo: FeedSourceFeedInfo
feedParams: FeedParams | undefined
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const {hasSession} = useSession()
const {openComposer} = useOpenComposer()
const isScreenFocused = useIsFocused()
@@ -168,10 +167,10 @@ export function ProfileFeedScreenInner({
<EmptyState
icon={HashtagWideIcon}
iconSize="2xl"
message={_(msg`This feed is empty.`)}
message={l`This feed is empty.`}
/>
)
}, [_])
}, [l])
const isVideoFeed = useMemo(() => {
const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri)
@@ -181,13 +180,17 @@ export function ProfileFeedScreenInner({
return IS_NATIVE && _isVideoFeed
}, [feedInfo])
const isTrending =
feedInfo.creatorDid.toLowerCase() === TRENDING_DID ||
feedInfo.creatorHandle.toLowerCase() === TRENDING_HANDLE
return (
<>
<ProfileFeedHeader info={feedInfo} />
<ProfileFeedHeader info={feedInfo} isTrending={isTrending} />
<FeedFeedbackProvider value={feedFeedback}>
<PostFeed
enabled
description={isTrending ? feedInfo.description : undefined}
feed={feed}
feedParams={feedParams}
pollInterval={60e3}
@@ -199,22 +202,20 @@ export function ProfileFeedScreenInner({
isVideoFeed={isVideoFeed}
/>
</FeedFeedbackProvider>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onScrollToTop}
label={_(msg`Load new posts`)}
label={l`Load new posts`}
showIndicator={hasNew}
/>
)}
{hasSession && (
<FAB
testID="composeFAB"
onPress={() => openComposer({logContext: 'Fab'})}
icon={<EditBigIcon size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityLabel={l`New post`}
accessibilityHint=""
/>
)}
@@ -3,6 +3,7 @@ import {View} from 'react-native'
import {AtUri} from '@atproto/api'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {TRENDING_HANDLE} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {makeCustomFeedLink, makeProfileLink} from '#/lib/routes/links'
import {shareUrl} from '#/lib/sharing'
@@ -24,20 +25,20 @@ import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
Heart2_Stroke2_Corner0_Rounded as Heart,
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilledIcon,
Heart2_Stroke2_Corner0_Rounded as HeartIcon,
} from '#/components/icons/Heart2'
import {
Pin_Filled_Corner0_Rounded as PinFilled,
Pin_Stroke2_Corner0_Rounded as Pin,
Pin_Filled_Corner0_Rounded as PinFilledIcon,
Pin_Stroke2_Corner0_Rounded as PinIcon,
} from '#/components/icons/Pin'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link'
import * as Menu from '#/components/Menu'
@@ -74,14 +75,20 @@ export function ProfileFeedHeaderSkeleton() {
width: 34,
},
]}>
<Pin size="lg" fill={t.atoms.text_contrast_low.color} />
<PinIcon size="lg" fill={t.atoms.text_contrast_low.color} />
</View>
</Layout.Header.Slot>
</Layout.Header.Outer>
)
}
export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
export function ProfileFeedHeader({
info,
isTrending,
}: {
info: FeedSourceFeedInfo
isTrending: boolean
}) {
const t = useTheme()
const {t: l, i18n} = useLingui()
const ax = useAnalytics()
@@ -188,105 +195,143 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content align="left">
<Button
label={l`Open feed info screen`}
style={[
a.justify_start,
{
paddingVertical: IS_WEB ? 2 : 4,
paddingRight: 8,
},
]}
onPress={() => {
playHaptic()
infoControl.open()
}}>
{({hovered, pressed}) => (
<>
<View
{isTrending ? (
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<View style={[a.flex_1]}>
<Text
style={[
a.absolute,
a.inset_0,
a.rounded_sm,
a.transition_all,
t.atoms.bg_contrast_25,
{
opacity: 0,
left: IS_WEB ? -2 : -4,
right: 0,
},
pressed && {
opacity: 1,
},
hovered && {
opacity: 1,
transform: [{scaleX: 1.01}, {scaleY: 1.1}],
},
a.text_md,
a.font_bold,
a.leading_snug,
gtMobile && a.text_lg,
]}
/>
numberOfLines={2}
emoji>
{info.displayName}
</Text>
</View>
<Button
label={l`Open feed info screen`}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onPress={() => {
playHaptic()
infoControl.open()
}}>
<ButtonIcon icon={EllipsisIcon} />
</Button>
</View>
) : (
<Button
label={l`Open feed info screen`}
style={[
a.justify_start,
{
paddingVertical: IS_WEB ? 2 : 4,
paddingRight: 8,
},
]}
onPress={() => {
playHaptic()
infoControl.open()
}}>
{({hovered, pressed}) => (
<>
<View
style={[
a.absolute,
a.inset_0,
a.rounded_sm,
a.transition_all,
t.atoms.bg_contrast_25,
{
opacity: 0,
left: IS_WEB ? -2 : -4,
right: 0,
},
pressed && {
opacity: 1,
},
hovered && {
opacity: 1,
transform: [{scaleX: 1.01}, {scaleY: 1.1}],
},
]}
/>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
{info.avatar && (
<UserAvatar size={36} type="algo" avatar={info.avatar} />
)}
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
{info.avatar && (
<UserAvatar
size={36}
type="algo"
avatar={info.avatar}
/>
)}
<View style={[a.flex_1]}>
<Text
style={[
a.text_md,
a.font_bold,
a.leading_snug,
gtMobile && a.text_lg,
]}
numberOfLines={2}
emoji>
{info.displayName}
</Text>
<View style={[a.flex_row, {gap: 6}]}>
<View style={[a.flex_1]}>
<Text
style={[
a.flex_shrink,
a.text_sm,
a.text_md,
a.font_bold,
a.leading_snug,
t.atoms.text_contrast_medium,
gtMobile && a.text_lg,
]}
numberOfLines={1}>
{sanitizeHandle(info.creatorHandle, '@')}
numberOfLines={2}
emoji>
{info.displayName}
</Text>
<View style={[a.flex_row, a.align_center, {gap: 2}]}>
<HeartFilled
size="xs"
fill={
likeUri
? t.palette.pink
: t.atoms.text_contrast_low.color
}
/>
<View style={[a.flex_row, a.gap_2xs]}>
<Text
style={[
a.flex_shrink,
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
t.atoms.text_contrast_high,
]}
numberOfLines={1}>
{formatCount(i18n, likeCount)}
{sanitizeHandle(info.creatorHandle, '@')}
</Text>
{likeCount > 0 ? (
<View
style={[a.flex_row, a.align_center, {gap: 2}]}>
<HeartFilledIcon
size="xs"
fill={
likeUri
? t.palette.pink
: t.atoms.text_contrast_low.color
}
style={[{width: 14, height: 14}]}
/>
<Text
style={[
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_high,
]}
numberOfLines={1}>
{formatCount(i18n, likeCount)}
</Text>
</View>
) : null}
</View>
</View>
</View>
<Ellipsis
size="md"
fill={t.atoms.text_contrast_low.color}
/>
</View>
</>
)}
</Button>
<EllipsisIcon
size="md"
fill={t.atoms.text_contrast_high.color}
/>
</View>
</>
)}
</Button>
)}
</Layout.Header.Content>
{hasSession && (
{!isTrending && hasSession ? (
<Layout.Header.Slot>
{isPinned ? (
<Menu.Root>
@@ -300,7 +345,10 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
variant="ghost"
shape="square"
color="secondary">
<PinFilled size="lg" fill={t.palette.primary_500} />
<PinFilledIcon
size="lg"
fill={t.palette.primary_500}
/>
</Button>
)
}}
@@ -310,23 +358,23 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
<Menu.Item
disabled={isFeedStateChangePending}
label={l`Unpin from home`}
onPress={onTogglePinned}>
onPress={() => void onTogglePinned()}>
<Menu.ItemText>{l`Unpin from home`}</Menu.ItemText>
<Menu.ItemIcon icon={X} position="right" />
<Menu.ItemIcon icon={XIcon} position="right" />
</Menu.Item>
<Menu.Item
disabled={isFeedStateChangePending}
label={
isSaved ? l`Remove from my feeds` : l`Save to my feeds`
}
onPress={onToggleSaved}>
onPress={() => void onToggleSaved()}>
<Menu.ItemText>
{isSaved
? l`Remove from my feeds`
: l`Save to my feeds`}
</Menu.ItemText>
<Menu.ItemIcon
icon={isSaved ? Trash : Plus}
icon={isSaved ? TrashIcon : PlusIcon}
position="right"
/>
</Menu.Item>
@@ -339,12 +387,12 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
variant="ghost"
shape="square"
color="secondary"
onPress={onTogglePinned}>
<ButtonIcon icon={Pin} size="lg" />
onPress={() => void onTogglePinned()}>
<ButtonIcon icon={PinIcon} size="lg" />
</Button>
)}
</Layout.Header.Slot>
)}
) : null}
</Layout.Header.Outer>
</Layout.Center>
<Dialog.Outer control={infoControl}>
@@ -358,7 +406,8 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
setLikeUri={setLikeUri}
likeCount={likeCount}
isPinned={isPinned}
onTogglePinned={onTogglePinned}
isTrending={isTrending}
onTogglePinned={() => void onTogglePinned()}
isFeedStateChangePending={isFeedStateChangePending}
/>
</Dialog.ScrollableInner>
@@ -373,6 +422,7 @@ function DialogInner({
setLikeUri,
likeCount,
isPinned,
isTrending,
onTogglePinned,
isFeedStateChangePending,
}: {
@@ -381,6 +431,7 @@ function DialogInner({
setLikeUri: (uri: string) => void
likeCount: number
isPinned: boolean
isTrending: boolean
onTogglePinned: () => void
isFeedStateChangePending: boolean
}) {
@@ -459,7 +510,9 @@ function DialogInner({
style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]}
numberOfLines={1}
onPress={() => control.close()}>
{sanitizeHandle(info.creatorHandle, '@')}
{info.creatorHandle === TRENDING_HANDLE
? l`Bluesky`
: sanitizeHandle(info.creatorHandle, '@')}
</InlineLinkText>
</Trans>
</Text>
@@ -472,12 +525,13 @@ function DialogInner({
color="secondary"
shape="round"
onPress={onPressShare}>
<ButtonIcon icon={Share} size="lg" />
<ButtonIcon icon={ShareIcon} size="lg" />
</Button>
</View>
<RichText value={info.description} style={[a.text_md]} />
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
{typeof likeCount === 'number' && (
{typeof likeCount === 'number' && likeCount > 0 ? (
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<InlineLinkText
label={l`View users who like this feed`}
to={makeCustomFeedLink(info.creatorDid, feedRkey, 'liked-by')}
@@ -487,41 +541,47 @@ function DialogInner({
Liked by <Plural value={likeCount} one="# user" other="# users" />
</Trans>
</InlineLinkText>
)}
</View>
{hasSession && (
</View>
) : null}
{hasSession ? (
<>
<View style={[a.flex_row, a.gap_sm, a.align_center, a.pt_sm]}>
<Button
disabled={isLikePending || isUnlikePending}
label={l`Like this feed`}
size="small"
color="secondary"
onPress={onToggleLiked}
style={[a.flex_1]}>
{isLiked ? (
<HeartFilled size="sm" fill={t.palette.pink} />
) : (
<ButtonIcon icon={Heart} />
)}
{!isTrending ? (
<View style={[a.flex_row, a.gap_sm, a.align_center, a.pt_sm]}>
<Button
disabled={isLikePending || isUnlikePending}
label={l`Like this feed`}
size="small"
color="secondary"
onPress={() => void onToggleLiked()}
style={[a.flex_1]}>
{isLiked ? (
<HeartFilledIcon size="sm" fill={t.palette.pink} />
) : (
<ButtonIcon icon={HeartIcon} />
)}
<ButtonText>
{isLiked ? <Trans>Unlike</Trans> : <Trans>Like</Trans>}
</ButtonText>
</Button>
<Button
disabled={isFeedStateChangePending}
label={isPinned ? l`Unpin feed` : l`Pin feed`}
size="small"
color={isPinned ? 'secondary' : 'primary'}
onPress={onTogglePinned}
style={[a.flex_1]}>
<ButtonText>
{isPinned ? <Trans>Unpin feed</Trans> : <Trans>Pin feed</Trans>}
</ButtonText>
<ButtonIcon icon={Pin} position="right" />
</Button>
</View>
<ButtonText>
{isLiked ? <Trans>Unlike</Trans> : <Trans>Like</Trans>}
</ButtonText>
</Button>
<Button
disabled={isFeedStateChangePending}
label={isPinned ? l`Unpin feed` : l`Pin feed`}
size="small"
color={isPinned ? 'secondary' : 'primary'}
onPress={onTogglePinned}
style={[a.flex_1]}>
<ButtonText>
{isPinned ? (
<Trans>Unpin feed</Trans>
) : (
<Trans>Pin feed</Trans>
)}
</ButtonText>
<ButtonIcon icon={PinIcon} position="right" />
</Button>
</View>
) : null}
<View style={[a.pt_xs, a.gap_lg]}>
<Divider />
@@ -541,7 +601,7 @@ function DialogInner({
<ButtonText>
<Trans>Report feed</Trans>
</ButtonText>
<ButtonIcon icon={CircleInfo} position="right" />
<ButtonIcon icon={CircleInfoIcon} position="right" />
</Button>
</View>
@@ -556,7 +616,7 @@ function DialogInner({
)}
</View>
</>
)}
) : null}
</View>
)
}
+34 -47
View File
@@ -5,9 +5,7 @@ import {
type AppBskyFeedDefs,
type AppBskyGraphDefs,
} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import * as bcp47Match from 'bcp-47-match'
@@ -48,7 +46,6 @@ import {
StarterPackCardSkeleton,
} from '#/screens/Search/components/StarterPackCard'
import {ExploreInterestsCard} from '#/screens/Search/modules/ExploreInterestsCard'
import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations'
import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics'
import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos'
import {atoms as a, native, platform, useTheme} from '#/alf'
@@ -79,7 +76,7 @@ import {
function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const handleOnPress = () => {
void item.onLoadMore()
@@ -87,7 +84,7 @@ function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
return (
<Button
label={_(msg`Load more`)}
label={l`Load more`}
onPress={handleOnPress}
style={[a.relative, a.w_full]}>
{({hovered, pressed}) => (
@@ -139,6 +136,7 @@ type ExploreScreenItems =
key: string
title: string
icon: React.ComponentType<SVGIconProps>
iconSize?: IcoProps['size']
searchButton?: {
label: string
metricsTag: Metrics['explore:module:searchButtonPress']['module']
@@ -154,10 +152,6 @@ type ExploreScreenItems =
type: 'trendingVideos'
key: string
}
| {
type: 'recommendations'
key: string
}
| {
type: 'profile'
key: string
@@ -220,7 +214,7 @@ export function Explore({
headerHeight: number
}) {
const ax = useAnalytics()
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const {data: preferences, error: preferencesError} = usePreferencesQuery()
const moderationOpts = useModerationOpts()
@@ -372,10 +366,11 @@ export function Explore({
i.push({
type: 'tabbedHeader',
key: 'suggested-accounts-header',
title: _(msg`Suggested accounts`),
title: l`Suggested accounts`,
icon: Person,
iconSize: 'md',
searchButton: {
label: _(msg`Search for more accounts`),
label: l`Search for more accounts`,
metricsTag: 'suggestedAccounts',
tab: 'user',
},
@@ -388,7 +383,7 @@ export function Explore({
i.push({
type: 'error',
key: 'suggestedUsersError',
message: _(msg`Failed to load suggested follows`),
message: l`Failed to load suggested follows`,
error: cleanError(suggestedUsersError),
})
} else {
@@ -436,7 +431,7 @@ export function Explore({
}
return i
}, [
_,
l,
moderationOpts,
suggestedUsers,
suggestedUsersIsLoading,
@@ -450,10 +445,11 @@ export function Explore({
i.push({
type: 'header',
key: 'suggested-feeds-header',
title: _(msg`Discover new feeds`),
title: l`Discover feeds`,
icon: ListSparkle,
iconSize: 'md',
searchButton: {
label: _(msg`Search for more feeds`),
label: l`Search for more feeds`,
metricsTag: 'suggestedFeeds',
tab: 'feed',
},
@@ -479,14 +475,14 @@ export function Explore({
i.push({
type: 'error',
key: 'suggestedFeedsError',
message: _(msg`Failed to load suggested feeds`),
message: l`Failed to load suggested feeds`,
error: cleanError(suggestedFeedsError),
})
} else if (preferencesError) {
i.push({
type: 'error',
key: 'preferencesError',
message: _(msg`Failed to load feeds preferences`),
message: l`Failed to load feeds preferences`,
error: cleanError(preferencesError),
})
} else {
@@ -516,7 +512,7 @@ export function Explore({
i.push({
type: 'loadMore',
key: 'loadMoreFeeds',
message: _(msg`Load more suggested feeds`),
message: l`Load more suggested feeds`,
isLoadingMore: isLoadingMoreFeeds,
onLoadMore: onLoadMoreFeeds,
})
@@ -527,21 +523,21 @@ export function Explore({
i.push({
type: 'error',
key: 'feedsError',
message: _(msg`Failed to load feeds`),
message: l`Failed to load feeds`,
error: cleanError(feedsError),
})
} else if (suggestedFeedsError) {
i.push({
type: 'error',
key: 'suggestedFeedsError',
message: _(msg`Failed to load suggested feeds`),
message: l`Failed to load suggested feeds`,
error: cleanError(suggestedFeedsError),
})
} else if (preferencesError) {
i.push({
type: 'error',
key: 'preferencesError',
message: _(msg`Failed to load feeds preferences`),
message: l`Failed to load feeds preferences`,
error: cleanError(preferencesError),
})
} else {
@@ -572,21 +568,21 @@ export function Explore({
i.push({
type: 'error',
key: 'feedsError',
message: _(msg`Failed to load feeds`),
message: l`Failed to load feeds`,
error: cleanError(feedsError),
})
} else if (suggestedFeedsError) {
i.push({
type: 'error',
key: 'suggestedFeedsError',
message: _(msg`Failed to load suggested feeds`),
message: l`Failed to load suggested feeds`,
error: cleanError(suggestedFeedsError),
})
} else if (preferencesError) {
i.push({
type: 'error',
key: 'preferencesError',
message: _(msg`Failed to load feeds preferences`),
message: l`Failed to load feeds preferences`,
error: cleanError(preferencesError),
})
} else {
@@ -607,7 +603,7 @@ export function Explore({
i.push({
type: 'loadMore',
key: 'loadMoreFeeds',
message: _(msg`Load more suggested feeds`),
message: l`Load more suggested feeds`,
isLoadingMore: isLoadingMoreFeeds,
onLoadMore: onLoadMoreFeeds,
})
@@ -618,21 +614,21 @@ export function Explore({
i.push({
type: 'error',
key: 'feedsError',
message: _(msg`Failed to load feeds`),
message: l`Failed to load feeds`,
error: cleanError(feedsError),
})
} else if (suggestedFeedsError) {
i.push({
type: 'error',
key: 'feedsError',
message: _(msg`Failed to load suggested feeds`),
message: l`Failed to load suggested feeds`,
error: cleanError(suggestedFeedsError),
})
} else if (preferencesError) {
i.push({
type: 'error',
key: 'preferencesError',
message: _(msg`Failed to load feeds preferences`),
message: l`Failed to load feeds preferences`,
error: cleanError(preferencesError),
})
} else {
@@ -642,7 +638,7 @@ export function Explore({
}
return i
}, [
_,
l,
ax,
useFullExperience,
suggestedFeeds,
@@ -662,9 +658,9 @@ export function Explore({
i.push({
type: 'header',
key: 'suggested-starterPacks-header',
title: _(msg`Starter Packs`),
title: l`Starter Packs`,
icon: StarterPack,
iconSize: 'xl',
iconSize: 'md',
})
if (isLoadingSuggestedSPs || isRefetchingSuggestedSPs) {
@@ -689,7 +685,7 @@ export function Explore({
return i
}, [
suggestedSPs,
_,
l,
isLoadingSuggestedSPs,
suggestedSPsError,
isRefetchingSuggestedSPs,
@@ -778,7 +774,7 @@ export function Explore({
return (
<View style={[a.pb_md]}>
<ModuleHeader.Container style={[a.pb_xs]}>
<ModuleHeader.Icon icon={item.icon} />
<ModuleHeader.Icon icon={item.icon} size={item.iconSize} />
<ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText>
{item.searchButton && (
<ModuleHeader.SearchButton
@@ -798,18 +794,11 @@ export function Explore({
)
}
case 'trendingTopics': {
return (
<View style={[a.pb_md]}>
<ExploreTrendingTopics />
</View>
)
return <ExploreTrendingTopics />
}
case 'trendingVideos': {
return <ExploreTrendingVideos />
}
case 'recommendations': {
return <ExploreRecommendations />
}
case 'profile': {
return (
<SuggestedProfileCard
@@ -1023,9 +1012,7 @@ export function Explore({
case 'preview:loadMoreError': {
return (
<LoadMoreRetryBtn
label={_(
msg`There was an issue fetching posts. Tap here to try again.`,
)}
label={l`There was an issue fetching posts. Tap here to try again.`}
onPress={handleOnPressRetry}
/>
)
@@ -1050,7 +1037,7 @@ export function Explore({
moderationOpts,
interestsDisplayNames,
useFullExperience,
_,
l,
fetchNextPageFeedPreviews,
],
)
+150 -3
View File
@@ -1,6 +1,6 @@
import {memo, useCallback, useMemo, useState} from 'react'
import {ActivityIndicator, View} from 'react-native'
import {type AppBskyFeedDefs} from '@atproto/api'
import {type AppBskyFeedDefs, type AppBskyGraphDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {urls} from '#/lib/constants'
@@ -15,6 +15,7 @@ import {augmentSearchQuery} from '#/lib/strings/helpers'
import {useActorSearch} from '#/state/queries/actor-search'
import {usePopularFeedsSearch} from '#/state/queries/feed'
import {useSearchPostsV2Query} from '#/state/queries/search-posts-v2'
import {useStarterPackSearch} from '#/state/queries/starter-pack-search'
import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
@@ -23,6 +24,7 @@ import {TabBar} from '#/view/com/pager/TabBar'
import {Post} from '#/view/com/post/Post'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {List} from '#/view/com/util/List'
import {StarterPackCard} from '#/screens/Search/components/StarterPackCard'
import {
hasPostOnlyFilters,
type SearchFilters,
@@ -41,6 +43,7 @@ let SearchResults = ({
query,
filters,
hasFilters,
fromMe,
activeTab,
onPageSelected,
headerHeight,
@@ -48,20 +51,26 @@ let SearchResults = ({
query: string
filters: SearchFilters
hasFilters: boolean
fromMe: boolean
activeTab: number
onPageSelected: (page: number) => void
headerHeight: number
}): React.ReactNode => {
const ax = useAnalytics()
const {t: l} = useLingui()
/*
* People/Feeds visibility keys off post-only filters: a `lang` filter applies
* to people and feeds too, so it must not hide those tabs (which would also
* regress the non-v2 legacy language dropdown). Other filters are post-only.
*/
const hasPostFilters = hasPostOnlyFilters(filters)
const hasPostFilters = hasPostOnlyFilters(filters) || fromMe
const activePage = hasPostFilters && activeTab > 1 ? 0 : activeTab
const tabShape = hasPostFilters ? 'filtered' : 'plain'
const isStarterPacksEnabled = ax.features.enabled(
ax.features.SearchStarterPacksV2Enable,
)
const sections = useMemo(() => {
if (!query && !hasFilters) return []
/*
@@ -106,11 +115,29 @@ let SearchResults = ({
<SearchScreenFeedsResults query={query} active={activePage === 3} />
),
},
noFilters &&
isStarterPacksEnabled && {
title: l`Starter packs`,
component: (
<SearchScreenStarterPackResults
query={query}
active={activePage === 4}
/>
),
},
].filter(Boolean) as {
title: string
component: React.ReactNode
}[]
}, [l, query, filters, hasFilters, hasPostFilters, activePage])
}, [
l,
query,
filters,
hasFilters,
hasPostFilters,
activePage,
isStarterPacksEnabled,
])
// There may be fewer tabs after changing the search options.
const selectedPage = activePage > sections.length - 1 ? 0 : activePage
@@ -692,3 +719,123 @@ function SearchFeedCard({
return <FeedCard.Default view={view} onPress={handleOnPress} />
}
let SearchScreenStarterPackResults = ({
query,
active,
}: {
query: string
active: boolean
}): React.ReactNode => {
const ax = useAnalytics()
const {t: l} = useLingui()
const [isPTR, setIsPTR] = useState(false)
const {
isFetched,
data: results,
isFetching,
error,
refetch,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
} = useStarterPackSearch({
query,
enabled: active,
})
const onPullToRefresh = useCallback(async () => {
setIsPTR(true)
await refetch()
setIsPTR(false)
}, [setIsPTR, refetch])
const onEndReached = useCallback(() => {
if (isFetching || !hasNextPage || error) return
void fetchNextPage()
}, [isFetching, error, hasNextPage, fetchNextPage])
const starterPacks = useMemo(() => {
return results?.pages.flatMap(page => page.starterPacks) || []
}, [results])
const fireTracking = useCallOnce(() => {
ax.metric('search:results:loaded', {
tab: 'starterPacks',
initialCount: starterPacks.length,
})
})
if (isFetched) {
fireTracking()
}
if (error) {
return (
<EmptyState
messageText={
shouldRetryError(error) || isNetworkError(error)
? l`Were sorry, but your search could not be completed. Please try again in a few minutes.`
: l`Were sorry, but your search could not be completed.`
}
error={cleanError(error)}
/>
)
}
return isFetched ? (
<>
{starterPacks.length ? (
<List
data={starterPacks}
renderItem={({
item,
index,
}: {
item: AppBskyGraphDefs.StarterPackView
index: number
}) => (
<View style={[a.px_lg, a.pb_lg, index === 0 && a.pt_lg]}>
<SearchStarterPack position={index} view={item} />
</View>
)}
keyExtractor={(item: AppBskyGraphDefs.StarterPackView) => item.uri}
refreshing={isPTR}
onRefresh={() => void onPullToRefresh()}
onEndReached={onEndReached}
desktopFixedHeight
ListFooterComponent={
<ListFooter
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
/>
}
/>
) : (
<EmptyState messageText={<NoResultsText query={query} />} />
)}
</>
) : (
<Loader />
)
}
SearchScreenStarterPackResults = memo(SearchScreenStarterPackResults)
function SearchStarterPack({
position,
view,
}: {
position: number
view: AppBskyGraphDefs.StarterPackView
}) {
const ax = useAnalytics()
const handleOnPress = () => {
ax.metric('search:result:press', {
tab: 'starterPacks',
resultType: 'starterPack',
position,
uri: view.uri,
})
}
return <StarterPackCard view={view} onPress={handleOnPress} />
}
+29 -23
View File
@@ -32,6 +32,7 @@ import {
unstableCacheProfileView,
useProfilesQuery,
} from '#/state/queries/profile'
import {extractFromMe} from '#/state/queries/search-posts-params'
import {useSession} from '#/state/session'
import {
countActiveFilters,
@@ -123,8 +124,18 @@ export function SearchScreenShell({
const tabParam = (route.params as {q?: string; tab?: TabParam})?.tab
const [activeTab, setActiveTab] = useState(() => getTabIndex(tabParam))
/*
* A raw `from:me` operator stays visible in the search input. Submitting the
* advanced dialog promotes it to a structured `from=me` filter and removes
* it from `q`; the API layer reconstructs the operator for post search.
*/
const {query, fromMe, filters, setFilters, hasFilters} = useQueryManager({
initialQuery: queryParam,
fixedParams,
})
// Query terms
const [searchText, setSearchText] = useState<string>(queryParam)
const [searchText, setSearchText] = useState<string>(query)
const searchTextRef = useRef(searchText)
const updateSearchText = useCallback((text: string) => {
searchTextRef.current = text
@@ -200,10 +211,6 @@ export function SearchScreenShell({
[accountHistory, setAccountHistory],
)
const {query, filters, setFilters, hasFilters} = useQueryManager({
initialQuery: queryParam,
fixedParams,
})
const showFilters = Boolean((query || hasFilters) && !showAutocomplete)
const onChangeLang = useCallback(
@@ -233,13 +240,13 @@ export function SearchScreenShell({
useEffect(() => {
if (IS_NATIVE) {
// eslint-disable-next-line react-hooks/set-state-in-effect
updateSearchText(queryParam)
updateSearchText(query)
}
}, [queryParam, updateSearchText])
}, [query, updateSearchText])
useFocusEffect(
useNonReactiveCallback(() => {
if (IS_WEB) {
updateSearchText(queryParam)
updateSearchText(query)
}
}),
)
@@ -329,11 +336,12 @@ export function SearchScreenShell({
])
const onSubmit = (source: 'typed' | 'autocomplete') => () => {
const nextQuery = searchTextRef.current
ax.metric('search:query', {
source,
filterCount: countActiveFilters(filters),
})
navigateToItem(searchTextRef.current)
navigateToItem(nextQuery)
}
const onSubmitAdvanced = useCallback(
@@ -474,17 +482,6 @@ export function SearchScreenShell({
}
}, [setShowAutocomplete])
const onSearchInputBlur = useCallback(() => {
/*
* Bind autocomplete visibility to focus state on native. On web this
* doesn't work because of focus management, which would render the
* autocomplete results uninteractable.
*/
if (IS_NATIVE) {
setShowAutocomplete(false)
}
}, [])
const focusSearchInput = useCallback(
(tab?: TabParam) => {
textInput.current?.focus()
@@ -609,7 +606,6 @@ export function SearchScreenShell({
ref={textInput}
value={searchText}
onFocus={onSearchInputFocus}
onBlur={onSearchInputBlur}
onChangeText={onChangeText}
onClearText={onPressClearQuery}
onSubmitEditing={onSubmit('typed')}
@@ -656,6 +652,7 @@ export function SearchScreenShell({
query={query}
filters={filters}
hasFilters={hasFilters}
fromMe={fromMe}
headerHeight={headerHeight}
focusSearchInput={focusSearchInput}
/>
@@ -707,6 +704,7 @@ let SearchScreenInner = ({
query,
filters,
hasFilters,
fromMe,
headerHeight,
focusSearchInput,
}: {
@@ -715,6 +713,7 @@ let SearchScreenInner = ({
query: string
filters: SearchFilters
hasFilters: boolean
fromMe: boolean
headerHeight: number
focusSearchInput: (tab?: TabParam) => void
}): React.ReactNode => {
@@ -731,6 +730,7 @@ let SearchScreenInner = ({
query={query}
filters={filters}
hasFilters={hasFilters}
fromMe={fromMe}
activeTab={activeTab}
headerHeight={headerHeight}
onPageSelected={onPageSelected}
@@ -781,8 +781,13 @@ function useQueryManager({
const navigation = useNavigation<NavigationProp>()
const route = useRoute()
// Free text only - structured filters live in sibling route params now.
// A raw Me operator remains part of the query until the advanced dialog
// promotes it to the structured `from` filter.
const query = initialQuery
const fromMe = useMemo(
() => extractFromMe(initialQuery).fromMe,
[initialQuery],
)
const filters = useMemo(() => {
const fromRoute = readSearchFilters(route.params as Record<string, unknown>)
@@ -814,11 +819,12 @@ function useQueryManager({
return useMemo(
() => ({
query,
fromMe,
filters,
setFilters,
hasFilters: hasActiveFilters(filters),
}),
[query, filters, setFilters],
[query, fromMe, filters, setFilters],
)
}
@@ -4,6 +4,7 @@ import {
countActiveFilters,
definedFilterParams,
filtersToApiParams,
hasActiveFilters,
hasPostOnlyFilters,
parseHistoryEntry,
readSearchFilters,
@@ -55,6 +56,14 @@ describe(`searchParams`, () => {
})
})
describe(`hasActiveFilters`, () => {
it(`includes the structured Me author filter`, () => {
expect(hasActiveFilters({})).toBe(false)
expect(hasActiveFilters({from: 'me'})).toBe(true)
expect(hasActiveFilters({author: 'alice'})).toBe(true)
})
})
describe(`definedFilterParams`, () => {
it(`omits absent keys entirely`, () => {
expect(definedFilterParams({author: 'alice'})).toEqual({author: 'alice'})
@@ -130,8 +139,9 @@ describe(`searchParams`, () => {
})
describe(`countActiveFilters`, () => {
it(`counts each set filter key once`, () => {
it(`counts each structured filter key once`, () => {
expect(countActiveFilters({})).toBe(0)
expect(countActiveFilters({from: 'me'})).toBe(1)
expect(
countActiveFilters({author: 'alice bob', domain: 'bsky.app'}),
).toBe(2)
@@ -152,6 +162,14 @@ describe(`searchParams`, () => {
})
})
it(`round-trips a promoted Me-only search`, () => {
const stored = serializeHistoryEntry('', {from: 'me'})
expect(parseHistoryEntry(stored)).toEqual({
q: '',
filters: {from: 'me'},
})
})
it(`round-trips query + filters`, () => {
const filters = {
author: 'alice',
@@ -7,20 +7,21 @@ import {
ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon,
} from '#/components/icons/Chevron'
import * as Menu from '#/components/Menu'
import {type FollowingFilter} from './utils'
import {type FromFilter} from './utils'
export function FollowingDropdown({
export function FromDropdown({
value,
onChange,
}: {
value: FollowingFilter
onChange: (value: FollowingFilter) => void
value: FromFilter
onChange: (value: FromFilter) => void
}) {
const {t: l} = useLingui()
const options: {value: FollowingFilter; label: string}[] = [
const options: {value: FromFilter; label: string}[] = [
{value: 'anyone', label: l`Anyone`},
{value: 'following', label: l`People you follow`},
{value: 'following', label: l`People I follow`},
{value: 'me', label: l`Me`},
]
const currentLabel = options.find(o => o.value === value)?.label ?? l`Anyone`
@@ -73,12 +73,68 @@ describe(`AdvancedSearchDialog serialize/parse`, () => {
expect(state.until).toBe('2024-02-01')
})
it(`keeps from:me in the query box rather than lifting it into a row`, () => {
const state = parseAdvancedSearch('from:me', {})
expect(state.query).toBe('from:me')
it(`lifts from:me typed into the query box into the "me" following filter`, () => {
const state = parseAdvancedSearch('cats from:me', {})
expect(state.query).toBe('cats')
expect(state.following).toBe('me')
expect(state.filters.find(f => f.field === 'authors')).toBeUndefined()
})
it(`promotes the "me" filter from q to a structured filter`, () => {
const out = serializeAdvancedSearch({
...emptySerializeState,
query: 'cats',
following: 'me',
})
expect(out.q).toBe('cats')
expect(out.filters.from).toBe('me')
expect(out.filters.following).toBeUndefined()
})
it(`strips from:me typed after selecting Me`, () => {
const out = serializeAdvancedSearch({
...emptySerializeState,
query: 'cats from:me',
following: 'me',
})
expect(out.q).toBe('cats')
expect(out.filters.from).toBe('me')
})
it(`promotes from:me typed after the dialog opens`, () => {
const out = serializeAdvancedSearch({
...emptySerializeState,
query: 'cats from:me',
})
expect(out.q).toBe('cats')
expect(out.filters.from).toBe('me')
})
it(`keeps a quoted from:me as query text`, () => {
const out = serializeAdvancedSearch({
...emptySerializeState,
query: 'cats "from:me"',
})
expect(out.q).toBe('cats "from:me"')
expect(out.filters.from).toBeUndefined()
})
it(`round-trips cats from:me through the "me" following filter`, () => {
const state = parseAdvancedSearch('cats from:me', {})
const out = serializeAdvancedSearch({
...emptySerializeState,
query: state.query,
following: state.following,
})
expect(out.q).toBe('cats')
expect(out.filters.from).toBe('me')
expect(out.filters.following).toBeUndefined()
})
it(`parses the structured Me filter into the From dropdown`, () => {
expect(parseAdvancedSearch('cats', {from: 'me'}).following).toBe('me')
})
it(`merges a query-box operator with the matching filter param`, () => {
const state = parseAdvancedSearch('hi from:bob', {author: 'alice'})
expect(state.query).toBe('hi')
@@ -22,12 +22,12 @@ import {SearchLanguageDropdown} from '../SearchLanguageDropdown'
import {ClearableDateField, DEFAULT_DATE} from './ClearableDateField'
import {ClearableInput} from './ClearableInput'
import {FilterBlock} from './FilterBlock'
import {FollowingDropdown} from './FollowingDropdown'
import {FromDropdown} from './FromDropdown'
import {MediaDropdown} from './MediaDropdown'
import {RepliesDropdown} from './RepliesDropdown'
import {
type AdvancedFilter,
type FollowingFilter,
type FromFilter,
makeFilter,
type MediaFilter,
parseAdvancedSearch,
@@ -124,7 +124,7 @@ function DialogInner({
const [media, setMedia] = useState<MediaFilter>(parsed.media)
const [replies, setReplies] = useState<RepliesFilter>(parsed.replies)
const [following, setFollowing] = useState<FollowingFilter>(parsed.following)
const [following, setFollowing] = useState<FromFilter>(parsed.following)
/*
* The date picker requires a valid date, so these always hold one. The
@@ -389,7 +389,9 @@ function DialogInner({
t.atoms.text_contrast_medium,
a.mb_sm,
]}>
<Trans>Include</Trans>
<Trans comment="Include search results with or without replies">
Include
</Trans>
</Text>
<View style={[a.flex_row]}>
<RepliesDropdown value={replies} onChange={setReplies} />
@@ -403,10 +405,12 @@ function DialogInner({
t.atoms.text_contrast_medium,
a.mb_sm,
]}>
<Trans>From</Trans>
<Trans comment="Filter search results by a specific post author">
From
</Trans>
</Text>
<View style={[a.flex_row]}>
<FollowingDropdown value={following} onChange={setFollowing} />
<FromDropdown value={following} onChange={setFollowing} />
</View>
</View>
</View>
@@ -1,4 +1,5 @@
import {
extractFromMe,
extractSearchPostsParams,
tokenizeQuery,
} from '#/state/queries/search-posts-params'
@@ -14,10 +15,11 @@ export type RepliesFilter = 'all' | 'none' | 'only'
export type MediaFilter = 'all' | 'media' | 'video'
/**
* Whether to limit results to authors the user follows. Serializes into the
* `following` sibling param ('following' -> following:true, anyone -> unset).
* Which authors to limit results to. 'following' serializes into the
* `following` sibling param (following=true); 'me' serializes into the `from`
* sibling param (from=me); 'anyone' leaves both unset.
*/
export type FollowingFilter = 'anyone' | 'following'
export type FromFilter = 'anyone' | 'following' | 'me'
export type FilterField = 'authors' | 'mentions' | 'domains' | 'urls' | 'tags'
@@ -93,7 +95,7 @@ export type DialogState = {
language: string
replies: RepliesFilter
media: MediaFilter
following: FollowingFilter
following: FromFilter
since: string
until: string
filters: AdvancedFilter[]
@@ -141,18 +143,27 @@ function isSimpleWord(word: string): boolean {
* populate "none of these words" - but only when their contents are simple
* words. Anything that wouldn't round-trip (embedded quotes, a negated phrase
* like -"a b", etc.) is left verbatim in the main "all of these words" query
* text instead of parsed.
* text instead of parsed. A bare `from:me` is pulled out into the `fromMe`
* flag, which drives the "Me" author filter (the backend resolves `me` to the
* viewer, so it never becomes a structured `author` value).
*/
function parseFreeText(raw: string): {
query: string
exactPhrase: string
negatedWords: string
fromMe: boolean
} {
const queryParts: string[] = []
const negatedWords: string[] = []
let exactPhrase = ''
let fromMe = false
for (const token of tokenizeQuery(raw)) {
// from:me -> "Me" author filter rather than free text.
if (token === 'from:me') {
fromMe = true
continue
}
// "phrase" -> "exact phrase", only if it has no inner quote.
if (token.startsWith('"') && token.endsWith('"') && token.length > 1) {
const inner = token.slice(1, -1)
@@ -176,6 +187,7 @@ function parseFreeText(raw: string): {
query: queryParts.join(' '),
exactPhrase,
negatedWords: negatedWords.join(' '),
fromMe,
}
}
@@ -208,7 +220,7 @@ export function parseAdvancedSearch(
* can be expressed as operators; exclude rows come solely from filter params.
*/
const lifted = extractSearchPostsParams(q)
const freeText = parseFreeText(lifted.q)
const {fromMe, ...freeText} = parseFreeText(lifted.q)
const includeValues: Record<FilterField, string> = {
authors: mergeValues(filters.author, lifted.author),
@@ -255,12 +267,24 @@ export function parseAdvancedSearch(
const since = filters.since ?? lifted.since
const until = filters.until ?? lifted.until
/*
* A raw `from:me` operator and the structured `from=me` filter both map to
* the "Me" author filter. The raw operator is promoted to the structured
* filter when the dialog is submitted.
*/
let following: FromFilter = 'anyone'
if (fromMe || filters.from === 'me') {
following = 'me'
} else if (filters.following === 'true') {
following = 'following'
}
return {
...freeText,
language: lang,
replies,
media,
following: filters.following === 'true' ? 'following' : 'anyone',
following,
since: since && isValidDate(since) ? since : '',
until: until && isValidDate(until) ? until : '',
filters: filterRows,
@@ -279,7 +303,7 @@ export function serializeAdvancedSearch(state: {
language: string
replies: RepliesFilter
media: MediaFilter
following: FollowingFilter
following: FromFilter
dateSince: string
dateSinceActive: boolean
dateUntil: string
@@ -352,7 +376,21 @@ export function serializeAdvancedSearch(state: {
if (state.replies === 'only') filters.replies = 'only'
if (state.media === 'media') filters.media = 'true'
else if (state.media === 'video') filters.video = 'true'
if (state.following === 'following') filters.following = 'true'
return {q: parts.join(' '), filters}
/*
* Re-parse the final text because a user can type `from:me` after the dialog
* has opened. Advanced submission removes every bare token from the search
* input and promotes it to the structured From filter. Quoted `"from:me"`
* remains ordinary query text.
*/
const {q, fromMe} = extractFromMe(parts.join(' '))
if (state.following === 'following') filters.following = 'true'
else if (state.following === 'me' || fromMe) filters.from = 'me'
/*
* Submitting the dialog promotes a raw `from:me` operator to `from=me`, so it
* leaves the search text and is represented by the From dropdown. The query
* hook reconstructs the backend operator at the API boundary.
*/
return {q, filters}
}
@@ -31,7 +31,7 @@ export function Container({
a.px_lg,
a.pt_2xl,
a.pb_md,
a.gap_sm,
a.gap_xs,
t.atoms.bg,
bottomBorder && [a.border_b, t.atoms.border_contrast_low],
style,
@@ -82,11 +82,13 @@ export function Icon({
icon: Comp,
size = 'lg',
}: Pick<React.ComponentProps<typeof ButtonIcon>, 'icon' | 'size'>) {
const t = useTheme()
const iconSize = iconSizes[size]
return (
<View style={[a.z_20, {width: iconSize, height: iconSize, marginLeft: -2}]}>
<Comp width={iconSize} />
<Comp width={iconSize} fill={t.atoms.text.color} />
</View>
)
}
@@ -94,7 +96,7 @@ export function Icon({
export function TitleText({style, ...props}: TextProps) {
return (
<Text
style={[a.font_semi_bold, a.flex_1, a.text_xl, style]}
style={[a.font_semi_bold, a.flex_1, a.text_lg, style]}
emoji
{...props}
/>
@@ -26,8 +26,10 @@ import * as bsky from '#/types/bsky'
export function StarterPackCard({
view,
onPress,
}: {
view: AppBskyGraphDefs.StarterPackView
onPress?: () => void
}) {
const t = useTheme()
const {_} = useLingui()
@@ -55,7 +57,10 @@ export function StarterPackCard({
to={link.to}
label={link.label}
onHoverIn={link.precache}
onPress={link.precache}>
onPress={() => {
link.precache()
onPress?.()
}}>
{s => (
<>
<SubtleHover hover={s.hovered || s.pressed} />
@@ -111,7 +116,10 @@ export function StarterPackCard({
to={link.to}
label={link.label}
onHoverIn={link.precache}
onPress={link.precache}
onPress={() => {
link.precache()
onPress?.()
}}
variant="solid"
color="secondary"
size="small"
@@ -1,15 +1,13 @@
import {useState} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useInterestsDisplayNames} from '#/lib/interests'
import {Nux, useSaveNux} from '#/state/queries/nuxs'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Shapes_Stroke2_Corner0_Rounded as Shapes} from '#/components/icons/Shapes'
import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Link} from '#/components/Link'
import * as Prompt from '#/components/Prompt'
@@ -17,7 +15,7 @@ import {Text} from '#/components/Typography'
export function ExploreInterestsCard() {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const {data: preferences} = usePreferencesQuery()
const interestsDisplayNames = useInterestsDisplayNames()
const {mutateAsync: saveNux} = useSaveNux()
@@ -41,16 +39,12 @@ export function ExploreInterestsCard() {
<>
<Prompt.Basic
control={trendingPrompt}
title={_(msg`Dismiss interests`)}
description={_(
msg`You can adjust your interests at any time from "Content and media" settings.`,
)}
confirmButtonCta={_(
msg({
message: `OK`,
comment: `Confirm button text.`,
}),
)}
title={l`Dismiss interests`}
description={l`You can adjust your interests at any time from "Content and media" settings.`}
confirmButtonCta={l({
message: `OK`,
comment: `Confirm button text.`,
})}
onConfirm={onConfirmClose}
/>
@@ -63,8 +57,8 @@ export function ExploreInterestsCard() {
t.atoms.border_contrast_medium,
]}>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<Shapes />
<Text style={[a.text_xl, a.font_semi_bold, a.leading_tight]}>
<ShapesIcon fill={t.atoms.text.color} />
<Text style={[a.text_lg, a.font_semi_bold]}>
<Trans>Your interests</Trans>
</Text>
</View>
@@ -96,7 +90,7 @@ export function ExploreInterestsCard() {
</Text>
<Link
label={_(msg`Edit interests`)}
label={l`Edit interests`}
to="/settings/interests"
size="small"
variant="solid"
@@ -108,7 +102,7 @@ export function ExploreInterestsCard() {
</Link>
<Button
label={_(msg`Hide this card`)}
label={l`Hide this card`}
size="small"
variant="ghost"
color="secondary"
@@ -1,120 +0,0 @@
import {View} from 'react-native'
import {type AppBskyUnspeccedDefs} from '@atproto/api'
import {Trans} from '@lingui/react/macro'
import {
DEFAULT_LIMIT as RECOMMENDATIONS_COUNT,
useTrendingTopics,
} from '#/state/queries/trending/useTrendingTopics'
import {useTrendingConfig} from '#/state/service-config'
import {atoms as a, useGutters, useTheme} from '#/alf'
import {Hashtag_Stroke2_Corner0_Rounded} from '#/components/icons/Hashtag'
import {
TrendingTopic,
TrendingTopicLink,
TrendingTopicSkeleton,
} from '#/components/TrendingTopics'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
// Note: This module is not currently used and may be removed in the future.
export function ExploreRecommendations() {
const {enabled} = useTrendingConfig()
return enabled ? <Inner /> : null
}
function Inner() {
const t = useTheme()
const ax = useAnalytics()
const gutters = useGutters([0, 'compact'])
const {data: trending, error, isLoading} = useTrendingTopics()
const noRecs = !isLoading && !error && !trending?.suggested?.length
const allFeeds = trending?.suggested && isAllFeeds(trending.suggested)
return error || noRecs ? null : (
<>
<View
style={[
a.flex_row,
IS_WEB
? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
: [a.p_lg, a.pt_2xl, a.gap_md],
a.border_b,
t.atoms.border_contrast_low,
]}>
<View style={[a.flex_1, a.gap_sm]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Hashtag_Stroke2_Corner0_Rounded
size="lg"
fill={t.palette.primary_500}
style={{marginLeft: -2}}
/>
<Text style={[a.text_2xl, a.font_bold, t.atoms.text]}>
<Trans>Recommended</Trans>
</Text>
</View>
{!allFeeds ? (
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
<Trans>
Content from across the network we think you might like.
</Trans>
</Text>
) : (
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
<Trans>Feeds we think you might like.</Trans>
</Text>
)}
</View>
</View>
<View style={[a.pt_md, a.pb_lg]}>
<View
style={[
a.flex_row,
a.justify_start,
a.flex_wrap,
{rowGap: 8, columnGap: 6},
gutters,
]}>
{isLoading ? (
Array(RECOMMENDATIONS_COUNT)
.fill(0)
.map((_, i) => <TrendingTopicSkeleton key={i} index={i} />)
) : !trending?.suggested ? null : (
<>
{trending.suggested.map(topic => (
<TrendingTopicLink
key={topic.link}
topic={topic}
onPress={() => {
ax.metric('recommendedTopic:click', {context: 'explore'})
}}>
{({hovered}) => (
<TrendingTopic
topic={topic}
style={[
hovered && [
t.atoms.border_contrast_high,
t.atoms.bg_contrast_25,
],
]}
/>
)}
</TrendingTopicLink>
))}
</>
)}
</View>
</View>
</>
)
}
function isAllFeeds(topics: AppBskyUnspeccedDefs.TrendingTopic[]) {
return topics.every(topic => {
const segments = topic.link.split('/').slice(1)
return segments[0] === 'profile' && segments[2] === 'feed'
})
}
@@ -1,27 +1,34 @@
import {useMemo} from 'react'
import {Pressable, View} from 'react-native'
import {type AppBskyUnspeccedDefs, moderateProfile} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Image} from 'expo-image'
import {
type AppBskyUnspeccedDefs,
moderateProfile,
RichText as RichTextApi,
} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useTrendingSettings} from '#/state/preferences/trending'
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
import {useTrendingConfig} from '#/state/service-config'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {atoms as a, useGutters, useTheme, type ViewStyleProp, web} from '#/alf'
import {formatCount} from '#/view/com/util/numeric/format'
import {atoms as a, useGutters, useTheme, type ViewStyleProp} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Flame_Stroke2_Corner1_Rounded as FlameIcon} from '#/components/icons/Flame'
import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending'
import {Link} from '#/components/Link'
import {RichText} from '#/components/RichText'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import * as ModuleHeader from '../components/ModuleHeader'
const TOPIC_COUNT = 5
const IMAGE_SIZE = 56
export function ExploreTrendingTopics() {
const {enabled} = useTrendingConfig()
const {trendingDisabled} = useTrendingSettings()
@@ -32,24 +39,36 @@ function Inner() {
const ax = useAnalytics()
const {data: trending, error, isLoading, isRefetching} = useGetTrendsQuery()
const noTopics = !isLoading && !error && !trending?.trends?.length
const showLoading = isLoading || isRefetching
return isLoading || isRefetching ? (
Array.from({length: TOPIC_COUNT}).map((__, i) => (
<TrendingTopicRowSkeleton key={i} withPosts={i === 0} />
))
) : error || !trending?.trends || noTopics ? null : (
<>
{trending.trends.map((trend, index) => (
<TrendRow
key={trend.link}
trend={trend}
rank={index + 1}
onPress={() => {
ax.metric('trendingTopic:click', {context: 'explore'})
}}
/>
))}
</>
if (!showLoading && (error || !trending?.trends || noTopics)) return null
return (
<View style={[a.pb_md]}>
<ModuleHeader.Container bottomBorder>
<ModuleHeader.Icon icon={TrendingIcon} size="md" />
<ModuleHeader.TitleText>
<Trans>Trending</Trans>
</ModuleHeader.TitleText>
</ModuleHeader.Container>
{showLoading
? Array.from({length: TOPIC_COUNT}).map((__, i) => (
<TrendingTopicRowSkeleton key={i} />
))
: trending?.trends.map((trend, index) => (
<TrendRow
key={trend.link}
trend={trend}
rank={index + 1}
onPress={() => {
ax.metric('trendingTopic:click', {
context: 'explore',
recId: trending.recId,
})
}}
/>
))}
</View>
)
}
@@ -65,22 +84,24 @@ export function TrendRow({
onPress?: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const {t: l, i18n} = useLingui()
const gutters = useGutters([0, 'base'])
const category = useCategoryDisplayName(trend?.category || 'other')
const age = Math.floor(
(Date.now() - new Date(trend.startedAt || Date.now()).getTime()) /
(1000 * 60 * 60),
)
const badgeType = trend.status === 'hot' ? 'hot' : age < 2 ? 'new' : age
const actors = useModerateTrendingActors(trend.actors)
const description = useMemo(() => {
if (!trend.description) return
const rt = new RichTextApi({text: trend.description})
rt.detectFacetsWithoutResolution()
return rt
}, [trend.description])
let imageUrl = null // TODO Image URL goes here when available. -dsb
return (
<Link
testID={trend.link}
label={_(msg`Browse topic ${trend.displayName}`)}
label={l`Browse topic ${trend.displayName}`}
to={trend.link}
onPress={onPress}
style={[a.border_b, t.atoms.border_contrast_low]}
@@ -88,52 +109,75 @@ export function TrendRow({
{({hovered, pressed}) => (
<>
<SubtleHover hover={hovered || pressed} native />
<View style={[gutters, a.w_full, a.py_lg, a.flex_row, a.gap_2xs]}>
<View style={[a.flex_1, a.gap_xs]}>
<View style={[a.flex_row]}>
<Text
style={[
a.text_md,
a.font_semi_bold,
a.leading_tight,
{width: 20},
]}>
<Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'>
{rank}.
</Trans>
</Text>
<Text
style={[a.text_md, a.font_semi_bold, a.leading_tight]}
numberOfLines={1}>
{trend.displayName}
</Text>
</View>
<View
style={[
a.flex_row,
a.gap_sm,
a.align_center,
{paddingLeft: 20},
]}>
{actors.length > 0 && (
<AvatarStack size={20} profiles={actors} />
)}
<Text
style={[
a.text_sm,
t.atoms.text_contrast_medium,
web(a.leading_snug),
]}
numberOfLines={1}>
{category}
</Text>
</View>
</View>
<View style={[a.flex_shrink_0]}>
<TrendingIndicator type={badgeType} />
</View>
</View>
<View style={[gutters, a.w_full, a.flex_row, a.py_md, a.gap_sm]}>
<Text
style={[
a.text_sm,
a.font_medium,
t.atoms.text_contrast_low,
{
fontVariant: ['tabular-nums'],
},
]}>
<Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'>
{rank}.
</Trans>
</Text>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
style={[a.text_sm, a.font_semi_bold, a.leading_snug]}
numberOfLines={1}>
{trend.displayName}
</Text>
{description ? (
<RichText
value={description}
disableLinks
style={[a.text_sm, t.atoms.text_contrast_medium]}
numberOfLines={2}
/>
) : null}
<View style={[a.mt_xs, a.flex_row, a.gap_sm, a.align_center]}>
{actors.length > 0 ? (
<AvatarStack size={24} profiles={actors} />
) : null}
<Text
style={[a.text_sm, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{trend.postCount >= 1000 ? (
<Trans comment="Over 1,000 posts">1K+ posts</Trans>
) : (
<Trans comment="'{postCount} {posts}', e.g., '1.2K posts'">
{formatCount(i18n, trend.postCount)}{' '}
{plural(trend.postCount, {one: 'post', other: 'posts'})}
</Trans>
)}
</Text>
</View>
</View>
{imageUrl ? (
<Image
source={{
uri: imageUrl,
}}
alt={trend.topic}
style={[
a.flex_0,
a.rounded_md,
t.atoms.bg_contrast_25,
{
width: IMAGE_SIZE,
height: IMAGE_SIZE,
},
]}
contentFit="cover"
accessible={true}
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
) : null}
</View>
{children}
</>
)}
@@ -141,96 +185,30 @@ export function TrendRow({
)
}
type TrendingIndicatorType = 'hot' | 'new' | number
function TrendingIndicator({type}: {type: TrendingIndicatorType | 'skeleton'}) {
const t = useTheme()
const {_} = useLingui()
const pillStyles = [
a.flex_row,
a.align_center,
a.gap_xs,
a.rounded_full,
{height: 28, paddingHorizontal: 10},
]
let Icon: React.ComponentType<SVGIconProps> | null = null
let text: string | null = null
let color: string | null = null
let backgroundColor: string | null = null
switch (type) {
case 'skeleton': {
return (
<View
style={[
pillStyles,
{backgroundColor: t.palette.contrast_25, width: 65, height: 28},
]}
/>
)
}
case 'hot': {
Icon = FlameIcon
color =
t.scheme === 'light' ? t.palette.negative_500 : t.palette.negative_950
backgroundColor =
t.scheme === 'light' ? t.palette.negative_50 : t.palette.negative_200
text = _(msg`Hot`)
break
}
case 'new': {
Icon = TrendingIcon
text = _(msg`New`)
color = t.palette.positive_600
backgroundColor = t.palette.positive_50
break
}
default: {
text = _(
msg({
message: `${type}h ago`,
comment:
'trending topic time spent trending. should be as short as possible to fit in a pill',
}),
)
color = t.atoms.text_contrast_medium.color
backgroundColor = t.atoms.bg_contrast_25.backgroundColor
break
}
}
return (
<View style={[pillStyles, {backgroundColor}]}>
{Icon && <Icon size="sm" style={{color}} />}
<Text style={[a.text_sm, a.font_medium, {color}]}>{text}</Text>
</View>
)
}
function useCategoryDisplayName(
// Unused atm, but leaving here so we don't lose localization. -dsb
export function useCategoryDisplayName(
category: AppBskyUnspeccedDefs.TrendView['category'],
) {
const {_} = useLingui()
const {t: l} = useLingui()
switch (category) {
case 'sports':
return _(msg`Sports`)
return l`Sports`
case 'politics':
return _(msg`Politics`)
return l`Politics`
case 'video-games':
return _(msg`Video Games`)
return l`Video Games`
case 'pop-culture':
return _(msg`Entertainment`)
return l`Entertainment`
case 'news':
return _(msg`News`)
return l`News`
case 'other':
default:
return null
}
}
export function TrendingTopicRowSkeleton({}: {withPosts: boolean}) {
export function TrendingTopicRowSkeleton() {
const t = useTheme()
const gutters = useGutters([0, 'base'])
@@ -239,32 +217,39 @@ export function TrendingTopicRowSkeleton({}: {withPosts: boolean}) {
style={[
gutters,
a.w_full,
a.py_lg,
a.py_md,
a.flex_row,
a.gap_2xs,
a.gap_sm,
a.border_b,
t.atoms.border_contrast_low,
]}>
<View style={[a.flex_1, a.gap_sm]}>
<View style={[a.flex_row, a.align_center]}>
<View style={[{width: 20}]}>
<LoadingPlaceholder
width={12}
height={12}
style={[a.rounded_full]}
/>
</View>
<LoadingPlaceholder width={90} height={17} />
</View>
<View style={[a.flex_row, a.gap_sm, a.align_center, {paddingLeft: 20}]}>
<View style={[{width: 20}]}>
<LoadingPlaceholder width={17} height={17} style={[a.rounded_full]} />
</View>
<View style={[a.flex_1, a.gap_2xs]}>
<LoadingPlaceholder width={90} height={17} />
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<LoadingPlaceholder width={70} height={16} />
<LoadingPlaceholder width={40} height={16} />
<LoadingPlaceholder width={60} height={16} />
</View>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<LoadingPlaceholder width={50} height={16} />
<LoadingPlaceholder width={70} height={16} />
<LoadingPlaceholder width={30} height={16} />
</View>
<View style={[a.flex_1, a.gap_sm]}>
<View style={[a.mt_xs, a.flex_row, a.gap_sm, a.align_center]}>
<LoadingPlaceholder
width={24}
height={24}
style={[a.rounded_full]}
/>
<LoadingPlaceholder width={60} height={16} />
</View>
</View>
</View>
<View style={[a.flex_shrink_0]}>
<TrendingIndicator type="skeleton" />
</View>
{/* TODO Image placeholder goes here when images are available. -dsb */}
</View>
)
}
+6 -2
View File
@@ -31,6 +31,8 @@ export type SearchFilters = {
video?: string
/** 'true' */
following?: string
/** 'me' */
from?: string
}
export const FILTER_PARAM_KEYS = [
@@ -51,6 +53,7 @@ export const FILTER_PARAM_KEYS = [
'media',
'video',
'following',
'from',
] as const
/**
@@ -76,13 +79,14 @@ export function readSearchFilters(
}
export function hasActiveFilters(filters: SearchFilters): boolean {
return FILTER_PARAM_KEYS.some(key => filters[key])
return countActiveFilters(filters) > 0
}
/**
* Number of active filter params, used for the "[+N filters]" pill in search
* history. Each set key counts once (a multi-value field like author counts as
* one filter regardless of how many handles it holds).
* one filter regardless of how many handles it holds). Raw query operators do
* not count until the advanced dialog promotes them to structured params.
*/
export function countActiveFilters(filters: SearchFilters): number {
return FILTER_PARAM_KEYS.filter(key => filters[key]).length
@@ -0,0 +1,263 @@
import {useCallback, useMemo} from 'react'
import {type ListRenderItemInfo, Text as RNText, View} from 'react-native'
import {type ModerationOpts} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {
type AllNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActivitySubscriptionsQuery} from '#/state/queries/activity-subscriptions'
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
import {List} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf'
import {SubscribeProfileDialog} from '#/components/activity-notifications/SubscribeProfileDialog'
import * as Admonition from '#/components/Admonition'
import {Button, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {
BellRinging_Filled_Corner0_Rounded as BellRingingFilledIcon,
BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon,
} from '#/components/icons/BellRinging'
import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import * as SettingsList from '../components/SettingsList'
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
import {PreferenceControls} from './components/PreferenceControls'
type Props = NativeStackScreenProps<
AllNavigatorParams,
'ActivityNotificationSettings'
>
export function ActivityNotificationSettingsScreen({}: Props) {
const t = useTheme()
const {t: l} = useLingui()
const {data: preferences, isError: isPreferencesError} =
useNotificationSettingsQuery()
const moderationOpts = useModerationOpts()
const {
data: subscriptions,
isPending,
isError: isSubscriptionsError,
error,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
} = useActivitySubscriptionsQuery()
const items = useMemo(() => {
if (!subscriptions) return []
return subscriptions.pages.flatMap(page => page.subscriptions)
}, [subscriptions])
const renderItem = useCallback(
({item}: ListRenderItemInfo<bsky.profile.AnyProfileView>) => {
if (!moderationOpts) return null
return (
<ActivitySubscriptionCard
profile={item}
moderationOpts={moderationOpts}
/>
)
},
[moderationOpts],
)
const onEndReached = useCallback(() => {
if (isFetchingNextPage || !hasNextPage || isSubscriptionsError) return
void fetchNextPage().catch(err => {
logger.error('Failed to load more activity subscriptions', {
message: err,
})
})
}, [isFetchingNextPage, hasNextPage, isSubscriptionsError, fetchNextPage])
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>Notifications</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<List
ListHeaderComponent={
<SettingsList.Container>
<SettingsList.Item style={[a.align_start]}>
<SettingsList.ItemIcon icon={BellRingingIcon} />
<ItemTextWithSubtitle
bold
titleText={l`Activity from others`}
subtitleText={l`Get notified about posts and replies from accounts you choose.`}
/>
</SettingsList.Item>
{isPreferencesError ? (
<View style={[a.px_xl, a.pt_md]}>
<Admonition.Admonition type="error">
<Trans>Failed to load notification settings.</Trans>
</Admonition.Admonition>
</View>
) : (
<View style={[a.px_xl]}>
<PreferenceControls
name="subscribedPost"
preference={preferences?.subscribedPost}
/>
</View>
)}
</SettingsList.Container>
}
data={items}
keyExtractor={keyExtractor}
renderItem={renderItem}
onEndReached={onEndReached}
onEndReachedThreshold={4}
ListEmptyComponent={
error ? null : (
<View style={[a.px_xl, a.py_md]}>
{!isPending ? (
<Admonition.Outer type="tip">
<Admonition.Row>
<Admonition.Icon />
<Admonition.Content>
<Admonition.Text>
<Trans>
Enable notifications for an account by visiting their
profile and pressing the{' '}
<RNText
style={[
a.font_semi_bold,
t.atoms.text_contrast_high,
]}>
bell icon
</RNText>{' '}
<BellRingingFilledIcon
size="xs"
style={t.atoms.text_contrast_high}
/>
.
</Trans>
</Admonition.Text>
<Admonition.Text>
<Trans>
If you want to restrict who can receive notifications
for your account's activity, you can change this in{' '}
<InlineLinkText
label={l`Privacy and Security settings`}
to={{screen: 'ActivityPrivacySettings'}}
style={[a.font_semi_bold]}>
Settings &rarr; Privacy and Security
</InlineLinkText>
.
</Trans>
</Admonition.Text>
</Admonition.Content>
</Admonition.Row>
</Admonition.Outer>
) : (
<View style={[a.flex_1, a.align_center, a.pt_xl]}>
<Loader size="lg" />
</View>
)}
</View>
)
}
ListFooterComponent={
<ListFooter
style={[items.length === 0 && a.border_transparent]}
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
hasNextPage={hasNextPage}
/>
}
windowSize={11}
/>
</Layout.Screen>
)
}
function keyExtractor(item: bsky.profile.AnyProfileView) {
return item.did
}
function ActivitySubscriptionCard({
profile: profileUnshadowed,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
}) {
const profile = useProfileShadow(profileUnshadowed)
const control = useDialogControl()
const {t: l} = useLingui()
const t = useTheme()
const preview = useMemo(() => {
const actSub = profile.viewer?.activitySubscription
if (actSub?.post && actSub?.reply) {
return l`Posts, Replies`
} else if (actSub?.post) {
return l`Posts`
} else if (actSub?.reply) {
return l`Replies`
}
return l`None`
}, [l, profile.viewer?.activitySubscription])
return (
<View style={[a.py_md, a.px_xl, a.border_t, t.atoms.border_contrast_low]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<View style={[a.flex_1, a.gap_2xs]}>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
inline
/>
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
{preview}
</Text>
</View>
<Button
label={l`Edit notifications from ${createSanitizedDisplayName(
profile,
)}`}
size="small"
color="primary"
variant="solid"
onPress={control.open}>
<ButtonText>
<Trans>Edit</Trans>
</ButtonText>
</Button>
</ProfileCard.Header>
</ProfileCard.Outer>
<SubscribeProfileDialog
control={control}
profile={profile}
moderationOpts={moderationOpts}
includeProfile
/>
</View>
)
}
@@ -156,7 +156,9 @@ export function Inner({
<>
<Divider />
<Text style={[a.font_semi_bold, a.text_md]}>
<Trans>From</Trans>
<Trans comment="Filter who you receive notifications from">
From
</Trans>
</Text>
<Toggle.Group
type="radio"
@@ -57,7 +57,6 @@ export function NotificationSettingsScreen({}: Props) {
const mentionDialogControl = Dialog.useDialogControl()
const quoteDialogControl = Dialog.useDialogControl()
const repostDialogControl = Dialog.useDialogControl()
const activityDialogControl = Dialog.useDialogControl()
const likeRepostDialogControl = Dialog.useDialogControl()
const repostRepostDialogControl = Dialog.useDialogControl()
const chatDialogControl = Dialog.useDialogControl()
@@ -217,9 +216,9 @@ export function NotificationSettingsScreen({}: Props) {
showSkeleton={!settings}
/>
</SettingsList.PressableItem>
<SettingsList.PressableItem
<SettingsList.LinkItem
label={l`Settings for activity from others`}
onPress={activityDialogControl.open}
to={{screen: 'ActivityNotificationSettings'}}
contentContainerStyle={[a.align_start]}>
<SettingsList.ItemIcon icon={BellRingingIcon} />
<ItemTextWithSubtitle
@@ -229,7 +228,7 @@ export function NotificationSettingsScreen({}: Props) {
}
showSkeleton={!settings}
/>
</SettingsList.PressableItem>
</SettingsList.LinkItem>
<SettingsList.PressableItem
label={l`Settings for notifications for likes of your reposts`}
onPress={likeRepostDialogControl.open}
@@ -358,19 +357,6 @@ export function NotificationSettingsScreen({}: Props) {
<Trans>Get notifications when people repost your posts.</Trans>
}
/>
<NotificationSettingsDialog
control={activityDialogControl}
name="subscribedPost"
icon={BellRingingIcon}
titleText={<Trans>Activity from others</Trans>}
subtitleText={
<Trans>
Get notifications when there's activity on posts you're subscribed
to.
</Trans>
}
allowDisableInApp={false}
/>
<NotificationSettingsDialog
control={likeRepostDialogControl}
name="likeViaRepost"
@@ -311,7 +311,7 @@ export function BadgeButton({
a.text_md,
a.font_normal,
a.text_right,
{color: pressed ? t.palette.contrast_300 : t.palette.primary_500},
{color: pressed ? t.palette.contrast_300 : t.atoms.text_link.color},
]}>
{label}
</Button.ButtonText>
+19 -2
View File
@@ -588,7 +588,7 @@ function VideoItemInner({
const {bottom} = useSafeAreaInsets()
const [isReady, setIsReady] = useState(!IS_ANDROID)
usePlaybackTelemetry({player, active})
usePlaybackTelemetry({player, active, playlist: embed.playlist})
useEventListener(player, 'timeUpdate', evt => {
if (IS_ANDROID && !isReady && evt.currentTime >= 0.05) {
@@ -625,10 +625,13 @@ function VideoItemInner({
function usePlaybackTelemetry({
player,
active,
playlist,
}: {
player: VideoPlayer
active: boolean
playlist: string
}) {
const ax = useAnalytics()
const telemetryRef = useRef<PlaybackTelemetry | null>(null)
useEffect(() => {
@@ -652,7 +655,21 @@ function usePlaybackTelemetry({
if (evt.status === 'readyToPlay') {
telemetryRef.current?.ready()
} else if (evt.status === 'error') {
telemetryRef.current?.error(evt.error?.message ?? 'unknown')
const message = evt.error?.message ?? 'unknown'
telemetryRef.current?.error(message)
/*
* Adjacent players are preloaded and can error before the user ever
* swipes to them - only count failures the user actually sees.
*/
if (active) {
ax.metric('video:playback:failed', {
surface: 'immersiveFeed',
presentation: 'video',
errorClass: 'PlayerError',
errorMessage: message.slice(0, 256),
playlist,
})
}
}
})
@@ -1,7 +1,9 @@
import {describe, expect, it} from '@jest/globals'
import {
appendFromMe,
buildSearchPostsV2Filters,
extractFromMe,
extractSearchPostsParams,
} from '#/state/queries/search-posts-params'
@@ -139,6 +141,39 @@ describe(`extractSearchPostsParams`, () => {
})
})
describe(`extractFromMe / appendFromMe`, () => {
it(`strips a bare from:me token and reports it`, () => {
expect(extractFromMe(`cats from:me`)).toEqual({q: `cats`, fromMe: true})
expect(extractFromMe(`from:me`)).toEqual({q: ``, fromMe: true})
})
it(`reports fromMe false when the token is absent`, () => {
expect(extractFromMe(`cats from:alice`)).toEqual({
q: `cats from:alice`,
fromMe: false,
})
})
it(`leaves a quoted from:me in the query text`, () => {
expect(extractFromMe(`"from:me"`)).toEqual({q: `"from:me"`, fromMe: false})
})
it(`re-appends the token only when the filter is active`, () => {
expect(appendFromMe(`cats`, true)).toBe(`cats from:me`)
expect(appendFromMe(`cats`, false)).toBe(`cats`)
expect(appendFromMe(``, true)).toBe(`from:me`)
})
it(`does not duplicate an existing from:me token`, () => {
expect(appendFromMe(`cats from:me`, true)).toBe(`cats from:me`)
})
it(`round-trips through extract and append`, () => {
const {q, fromMe} = extractFromMe(`cats from:me`)
expect(appendFromMe(q, fromMe)).toBe(`cats from:me`)
})
})
describe(`buildSearchPostsV2Filters`, () => {
it(`maps embedded operators alone into v2 plural params`, () => {
expect(
+1 -1
View File
@@ -327,7 +327,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
count += page.feeds.length
}
if (count < limit && (data?.pages.length || 0) < 6) {
query.fetchNextPage()
void query.fetchNextPage()
lastPageCountRef.current = data?.pages?.length || 0
}
}, [query, limit])
+24
View File
@@ -83,6 +83,30 @@ export function tokenizeQuery(raw: string): string[] {
return tokens
}
/**
* Splits a bare `from:me` token out of a query. The "Me" author filter always
* travels inside `q` as a `from:me` token (the backend resolves `me` to the
* viewer), but the UI never shows it as text: the search input strips it for
* display and the advanced-search dialog represents it in the From dropdown.
* Tokenization keeps quoted phrases intact, so a `from:me` inside quotes stays
* in the query text.
*/
export function extractFromMe(query: string): {q: string; fromMe: boolean} {
const tokens = tokenizeQuery(query)
const kept = tokens.filter(token => token !== 'from:me')
return {q: kept.join(' '), fromMe: kept.length !== tokens.length}
}
/**
* Re-appends the `from:me` token when the "Me" author filter is active.
* Idempotent: a query that already carries a bare `from:me` is returned as-is.
*/
export function appendFromMe(query: string, fromMe: boolean): string {
if (!fromMe) return query
if (tokenizeQuery(query).includes('from:me')) return query
return query ? `${query} from:me` : 'from:me'
}
/**
* Lifts the operators that `app.bsky.feed.searchPosts` accepts as structured
* params out of the free-text query, so the backend filters on them directly.
+6 -3
View File
@@ -16,6 +16,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAgent} from '#/state/session'
import {type SearchFilters} from '#/screens/Search/searchParams'
import {
appendFromMe,
buildSearchPostsV2Filters,
extractSearchPostsParams,
} from './search-posts-params'
@@ -51,10 +52,11 @@ export function useSearchPostsV2Query({
const moderationOpts = useModerationOpts()
const selectArgs = useMemo(
() => ({
isSearchingSpecificUser: /from:(\w+)/.test(query) || !!filters?.author,
isSearchingSpecificUser:
/from:(\w+)/.test(query) || !!filters?.author || filters?.from === 'me',
moderationOpts,
}),
[query, filters?.author, moderationOpts],
[query, filters?.author, filters?.from, moderationOpts],
)
const lastRun = useRef<{
data: InfiniteData<AppBskyFeedSearchPostsV2.OutputSchema>
@@ -78,9 +80,10 @@ export function useSearchPostsV2Query({
*/
const {q, ...embedded} = extractSearchPostsParams(query)
const builtFilters = buildSearchPostsV2Filters(embedded, filters)
const finalQuery = appendFromMe(q, filters?.from === 'me')
const res = await agent.app.bsky.feed.searchPostsV2({
...builtFilters,
query: q,
query: finalQuery,
limit: 25,
cursor: pageParam,
/*
+75
View File
@@ -0,0 +1,75 @@
import {type AppBskyGraphSearchStarterPacksV2} from '@atproto/api'
import {
type InfiniteData,
keepPreviousData,
type QueryKey,
useInfiniteQuery,
} from '@tanstack/react-query'
import {STALE} from '#/state/queries'
import {useAgent} from '#/state/session'
export const RQKEY_ROOT = 'starter-pack-search'
export const RQKEY = (query: string, limit?: number) => [
RQKEY_ROOT,
query,
limit,
]
export function useStarterPackSearch({
query,
enabled,
maintainData,
limit = 25,
}: {
query: string
enabled?: boolean
maintainData?: boolean
limit?: number
}) {
const agent = useAgent()
return useInfiniteQuery<
AppBskyGraphSearchStarterPacksV2.OutputSchema,
Error,
InfiniteData<AppBskyGraphSearchStarterPacksV2.OutputSchema>,
QueryKey,
string | undefined
>({
staleTime: STALE.MINUTES.FIVE,
queryKey: RQKEY(query, limit),
queryFn: async ({pageParam}) => {
const res = await agent.app.bsky.graph.searchStarterPacksV2({
q: query,
limit,
cursor: pageParam,
})
return res.data
},
enabled: enabled && !!query,
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
placeholderData: maintainData ? keepPreviousData : undefined,
select,
})
}
function select(
data: InfiniteData<AppBskyGraphSearchStarterPacksV2.OutputSchema>,
) {
// enforce uniqueness
const uris = new Set()
return {
...data,
pages: data.pages.map(page => ({
...page,
starterPacks: page.starterPacks.filter(starterPack => {
if (uris.has(starterPack.uri)) {
return false
}
uris.add(starterPack.uri)
return true
}),
})),
}
}
+34 -10
View File
@@ -6,6 +6,7 @@ import {
aggregateUserInterests,
createBskyTopicsHeader,
} from '#/lib/api/feed/utils'
import {logger} from '#/logger'
import {getContentLanguages} from '#/state/preferences/languages'
import {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences'
@@ -13,24 +14,41 @@ import {useAgent} from '#/state/session'
export const DEFAULT_LIMIT = 5
export const createGetTrendsQueryKey = () => ['trends']
type QueryProps = {
limit?: number
refetchOnWindowFocus?: boolean
}
export function useGetTrendsQuery() {
function dedupe<T extends {link: string}>(trends: T[]): T[] {
const seen = new Set<string>()
return trends.filter(trend => {
if (seen.has(trend.link)) return false
seen.add(trend.link)
return true
})
}
export const createGetTrendsQueryKey = (limit?: number) =>
limit === undefined ? ['trends'] : ['trends', {limit}]
export function useGetTrendsQuery(props: QueryProps = {}) {
const agent = useAgent()
const {data: preferences} = usePreferencesQuery()
const limit = props.limit ?? DEFAULT_LIMIT
const mutedWords = useMemo(() => {
return preferences?.moderationPrefs?.mutedWords || []
}, [preferences?.moderationPrefs])
return useQuery({
enabled: !!preferences,
refetchOnWindowFocus: props.refetchOnWindowFocus,
staleTime: STALE.MINUTES.THREE,
queryKey: createGetTrendsQueryKey(),
queryKey: createGetTrendsQueryKey(limit),
queryFn: async () => {
const contentLangs = getContentLanguages().join(',')
const {data} = await agent.app.bsky.unspecced.getTrends(
{
limit: DEFAULT_LIMIT,
limit,
},
{
headers: {
@@ -39,17 +57,23 @@ export function useGetTrendsQuery() {
},
},
)
if (!data.recIdStr) {
logger.debug('useGetTrendsQuery response missing recIdStr')
}
return data
},
select: useCallback(
(data: AppBskyUnspeccedGetTrends.OutputSchema) => {
return {
trends: (data.trends ?? []).filter(t => {
return !hasMutedWord({
mutedWords,
text: t.topic + ' ' + t.displayName + ' ' + t.category,
})
}),
recId: data.recIdStr,
trends: dedupe(
(data.trends ?? []).filter(t => {
return !hasMutedWord({
mutedWords,
text: `${t.topic} ${t.displayName} ${t.category}`,
})
}),
),
}
},
[mutedWords],
@@ -1,74 +0,0 @@
import {useCallback, useMemo} from 'react'
import {type AppBskyUnspeccedDefs, hasMutedWord} from '@atproto/api'
import {useQuery} from '@tanstack/react-query'
import {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
export type TrendingTopic = AppBskyUnspeccedDefs.TrendingTopic
type Response = {
topics: TrendingTopic[]
suggested: TrendingTopic[]
}
export const DEFAULT_LIMIT = 14
function dedup(topics: TrendingTopic[]): TrendingTopic[] {
const seen = new Set<string>()
return topics.filter(t => {
if (seen.has(t.link)) return false
seen.add(t.link)
return true
})
}
export const trendingTopicsQueryKey = ['trending-topics']
export function useTrendingTopics() {
const agent = useAgent()
const {data: preferences} = usePreferencesQuery()
const mutedWords = useMemo(
() => preferences?.moderationPrefs?.mutedWords ?? [],
[preferences?.moderationPrefs?.mutedWords],
)
return useQuery<Response>({
refetchOnWindowFocus: true,
staleTime: STALE.MINUTES.THREE,
queryKey: trendingTopicsQueryKey,
async queryFn() {
const {data} = await agent.app.bsky.unspecced.getTrendingTopics({
limit: DEFAULT_LIMIT,
})
return {
topics: data.topics ?? [],
suggested: data.suggested ?? [],
}
},
select: useCallback(
(data: Response) => {
return {
topics: dedup(
data.topics.filter(t => {
return !hasMutedWord({
mutedWords,
text: `${t.topic} ${t.displayName ?? ''} ${t.description ?? ''}`,
})
}),
),
suggested: dedup(
data.suggested.filter(t => {
return !hasMutedWord({
mutedWords,
text: `${t.topic} ${t.displayName ?? ''} ${t.description ?? ''}`,
})
}),
),
}
},
[mutedWords],
),
})
}
@@ -6,10 +6,9 @@ import {
useState,
} from 'react'
import {
type NativeSyntheticEvent,
Text as RNText,
TextInput as RNTextInput,
type TextInputSelectionChangeEventData,
type TextInputSelectionChangeEvent,
View,
} from 'react-native'
import {type PasteEventPayload, TextInputWrapper} from 'expo-paste-input'
@@ -141,7 +140,7 @@ export function TextInput({
)
const onSelectionChange = useCallback(
(evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
(evt: TextInputSelectionChangeEvent) => {
// NOTE we track the input selection using a ref to avoid excessive renders -prf
textInputSelection.current = evt.nativeEvent.selection
},
@@ -150,7 +149,7 @@ export function TextInput({
const onSelectAutocompleteItem = useCallback(
(item: string) => {
onChangeText(
void onChangeText(
insertMentionAt(
richtext.text,
textInputSelection.current?.start || 0,
@@ -201,7 +200,9 @@ export function TextInput({
style={[
inputTextStyle,
{
color: segment.facet ? t.palette.primary_500 : t.atoms.text.color,
color: segment.facet
? t.atoms.text_link.color
: t.atoms.text.color,
marginTop: -1,
},
]}>
@@ -217,7 +218,7 @@ export function TextInput({
<RNTextInput
testID="composerTextInput"
ref={textInput}
onChangeText={onChangeText}
onChangeText={(newText: string) => void onChangeText(newText)}
onSelectionChange={onSelectionChange}
placeholder={placeholder}
placeholderTextColor={t.atoms.text_contrast_low.color}
+47 -2
View File
@@ -17,6 +17,7 @@ import {
AppBskyEmbedImages,
AppBskyEmbedVideo,
type AppBskyFeedDefs,
type RichText as RichTextType,
} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
@@ -50,7 +51,7 @@ import {List, type ListRef} from '#/view/com/util/List'
import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
import {useBreakpoints, useLayoutBreakpoints} from '#/alf'
import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf'
import {
AgeAssuranceDismissibleFeedBanner,
useInternalState as useAgeAssuranceBannerState,
@@ -60,9 +61,11 @@ import {
PostFeedVideoGridRow,
PostFeedVideoGridRowPlaceholder,
} from '#/components/feeds/PostFeedVideoGridRow'
import {FeedTrendingTopicsInterstitial} from '#/components/interstitials/FeedTrendingTopics'
import {TrendingInterstitial} from '#/components/interstitials/Trending'
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils'
import {RichText} from '#/components/RichText'
import {useAnalytics} from '#/analytics'
import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner'
@@ -104,6 +107,11 @@ type FeedRow =
type: 'fallbackMarker'
key: string
}
| {
type: 'description'
key: string
value: RichTextType
}
| {
type: 'sliceItem'
key: string
@@ -140,6 +148,10 @@ type FeedRow =
type: 'interstitialTrending'
key: string
}
| {
type: 'interstitialFeedTrendingTopics'
key: string
}
| {
type: 'interstitialTrendingVideos'
key: string
@@ -189,6 +201,7 @@ const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY
let PostFeed = ({
feed,
description,
feedParams,
ignoreFilterFor,
style,
@@ -211,6 +224,7 @@ let PostFeed = ({
isVideoFeed = false,
}: {
feed: FeedDescriptor
description?: RichTextType
feedParams?: FeedParams
ignoreFilterFor?: string
style?: StyleProp<ViewStyle>
@@ -227,13 +241,14 @@ let PostFeed = ({
progressViewOffset?: number
desktopFixedHeightOffset?: number
ListHeaderComponent?: () => React.ReactElement
extraData?: any
extraData?: Record<string, unknown>
savedFeedConfig?: AppBskyActorDefs.SavedFeed
initialNumToRender?: number
isVideoFeed?: boolean
lastFetchDate?: () => number
}): React.ReactNode => {
const ax = useAnalytics()
const t = useTheme()
const {t: l} = useLingui()
const queryClient = useQueryClient()
const {currentAccount, hasSession} = useSession()
@@ -384,6 +399,7 @@ let PostFeed = ({
* Cached value of whether the current feed was selected at startup. We don't
* want this to update when user swipes.
*/
// oxlint-disable-next-line react/hook-use-state
const [isCurrentFeedAtStartupSelected] = useState(selectedFeed === feed)
const blockedOrMutedAuthors = usePostAuthorShadowFilter(
@@ -540,6 +556,11 @@ let PostFeed = ({
key: 'composerPrompt-' + sliceIndex,
})
}
} else if (sliceIndex === 1) {
arr.push({
type: 'interstitialFeedTrendingTopics',
key: 'interstitialFeedTrendingTopics-' + sliceIndex,
})
} else if (sliceIndex === 15) {
if (areVideoFeedsEnabled && !trendingVideoDisabled) {
arr.push({
@@ -670,8 +691,17 @@ let PostFeed = ({
}
}
if (description?.text) {
arr.unshift({
key: 'description',
type: 'description',
value: description,
})
}
return arr
}, [
description,
isFetched,
isError,
isEmpty,
@@ -783,6 +813,18 @@ let PostFeed = ({
return <PostFeedLoadingPlaceholder />
} else if (row.type === 'feedShutdownMsg') {
return <FeedShutdownMsg feedUri={feedUriOrActorDid} />
} else if (row.type === 'description') {
return (
<RichText
value={row.value}
style={[
a.m_md,
a.text_md,
a.leading_snug,
t.atoms.text_contrast_high,
]}
/>
)
} else if (row.type === 'interstitialFollows') {
return <SuggestedFollows feed={feed} />
} else if (row.type === 'interstitialProgressGuide') {
@@ -791,6 +833,8 @@ let PostFeed = ({
return <AgeAssuranceDismissibleFeedBanner />
} else if (row.type === 'interstitialTrending') {
return <TrendingInterstitial />
} else if (row.type === 'interstitialFeedTrendingTopics') {
return <FeedTrendingTopicsInterstitial />
} else if (row.type === 'liveEventFeedsAndTrendingBanner') {
return <DiscoverFeedLiveEventFeedsAndTrendingBanner />
} else if (row.type === 'composerPrompt') {
@@ -881,6 +925,7 @@ let PostFeed = ({
feedTab,
feedCacheKey,
onPressShowLess,
t,
],
)
+2 -1
View File
@@ -58,7 +58,8 @@ export function ViewFullThread({uri}: {uri: string}) {
<Text
style={[
a.text_md,
{color: t.palette.primary_500, paddingTop: 18, paddingBottom: 4},
t.atoms.text_link,
{paddingTop: 18, paddingBottom: 4},
]}>
{/* HACKFIX: Trans isn't working after SDK 53 upgrade -sfn */}
{l`View full thread`}
+31 -12
View File
@@ -18,12 +18,25 @@ LogBox.ignoreAllLogs()
const BTN = {height: 1, width: 1, backgroundColor: 'red'}
/*
* This component is mounted inside <Fragment key={currentAccount?.did}> in
* App.tsx, so it fully remounts whenever the account changes (sign-in /
* sign-out). If the "proxy configured" flag lived only in React state it would
* reset to false on every remount, hiding the sign-in buttons. Keeping it at
* module level lets it survive remounts so the sign-in buttons stay visible
* across sign-out during multi-account flows. Module state still resets when
* the app relaunches with cleared state at the start of each flow, which is the
* desired gating behavior.
*/
let hasConfiguredProxy = false
export function TestCtrls() {
const agent = useAgent()
const queryClient = useQueryClient()
const {logoutEveryAccount, login} = useSessionApi()
const onboardingDispatch = useOnboardingDispatch()
const {setShowLoggedOut} = useLoggedOutViewControls()
const [isProxyConfigured, setIsProxyConfigured] = useState(hasConfiguredProxy)
const onPressSignInAlice = async () => {
console.info('[E2E] Signing in as Alice')
await login(
@@ -63,21 +76,27 @@ export function TestCtrls() {
const header = `${proxyHeader}#bsky_appview`
BLUESKY_PROXY_HEADER.set(header)
agent.configureProxy(header as any)
hasConfiguredProxy = true
setIsProxyConfigured(true)
}}
style={BTN}
/>
<Pressable
testID="e2eSignInAlice"
onPress={onPressSignInAlice}
accessibilityRole="button"
style={BTN}
/>
<Pressable
testID="e2eSignInBob"
onPress={onPressSignInBob}
accessibilityRole="button"
style={BTN}
/>
{isProxyConfigured && (
<>
<Pressable
testID="e2eSignInAlice"
onPress={onPressSignInAlice}
accessibilityRole="button"
style={BTN}
/>
<Pressable
testID="e2eSignInBob"
onPress={onPressSignInBob}
accessibilityRole="button"
style={BTN}
/>
</>
)}
<Pressable
testID="e2eSignOut"
onPress={() => logoutEveryAccount('Settings')}
@@ -5,7 +5,7 @@ import {
useTrendingSettings,
useTrendingSettingsApi,
} from '#/state/preferences/trending'
import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics'
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
import {useTrendingConfig} from '#/state/service-config'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
@@ -30,8 +30,14 @@ function Inner() {
const ax = useAnalytics()
const trendingPrompt = Prompt.usePromptControl()
const {setTrendingDisabled} = useTrendingSettingsApi()
const {data: trending, error, isLoading} = useTrendingTopics()
const noTopics = !isLoading && !error && !trending?.topics?.length
const {
data: trending,
error,
isLoading,
} = useGetTrendsQuery({
refetchOnWindowFocus: true,
})
const noTopics = !isLoading && !error && !trending?.trends?.length
const onConfirmHide = () => {
ax.metric('trendingTopics:hide', {context: 'sidebar'})
@@ -82,14 +88,17 @@ function Inner() {
/>
</View>
))
) : !trending?.topics ? null : (
) : !trending?.trends ? null : (
<>
{trending.topics.slice(0, TRENDING_LIMIT).map((topic, i) => (
{trending.trends.slice(0, TRENDING_LIMIT).map((topic, i) => (
<TrendingTopicLink
key={topic.link}
topic={topic}
onPress={() => {
ax.metric('trendingTopic:click', {context: 'sidebar'})
ax.metric('trendingTopic:click', {
context: 'sidebar',
recId: trending.recId,
})
}}>
{({hovered}) => (
<View style={[a.flex_1, a.flex_row, a.gap_xs]}>