Merge remote-tracking branch 'origin/main' into app-2670
# Conflicts: # src/analytics/features/types.ts
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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', {})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
() => ({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 can’t 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>
|
||||
|
||||
@@ -4,7 +4,6 @@ import {type AppBskyFeedDefs, type ModerationDecision} from '@atproto/api'
|
||||
export enum PostEmbedViewContext {
|
||||
ThreadHighlighted = 'ThreadHighlighted',
|
||||
Feed = 'Feed',
|
||||
FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
|
||||
ChatMessage = 'ChatMessage',
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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]}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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()]
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export type CommonNavigatorParams = {
|
||||
ActivityPrivacySettings: undefined
|
||||
ContentAndMediaSettings: undefined
|
||||
NotificationSettings: undefined
|
||||
ActivityNotificationSettings: undefined
|
||||
InterestsSettings: undefined
|
||||
AboutSettings: undefined
|
||||
AppIconSettings: undefined
|
||||
|
||||
+549
-514
File diff suppressed because one or more lines are too long
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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`We’re sorry, but your search could not be completed. Please try again in a few minutes.`
|
||||
: l`We’re 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} />
|
||||
}
|
||||
|
||||
@@ -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
-6
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 → 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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
/*
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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]}>
|
||||
|
||||
Reference in New Issue
Block a user