Merge conflicts

This commit is contained in:
Eric Bailey
2026-01-14 15:19:25 -06:00
parent 85eac1f505
commit d5a383bc58
15 changed files with 361 additions and 58 deletions
+1
View File
@@ -557,6 +557,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
a.align_center,
a.justify_center,
a.curve_continuous,
a.overflow_hidden,
baseStyles,
style,
...(state.hovered || state.pressed
+8 -2
View File
@@ -359,7 +359,13 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
)
}
export function Handle({difference = false}: {difference?: boolean}) {
export function Handle({
difference = false,
fill,
}: {
difference?: boolean
fill?: string
}) {
const t = useTheme()
const {_} = useLingui()
const {screenReaderEnabled} = useA11y()
@@ -390,7 +396,7 @@ export function Handle({difference = false}: {difference?: boolean}) {
opacity: 0.75,
}
: {
backgroundColor: t.palette.contrast_975,
backgroundColor: fill || t.palette.contrast_975,
opacity: 0.5,
},
]}
+7 -5
View File
@@ -167,6 +167,7 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
a.gap_sm,
a.px_md,
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
@@ -193,7 +194,6 @@ export function ItemText({children, style}: ItemTextProps) {
a.text_md,
a.font_semi_bold,
t.atoms.text_contrast_high,
{paddingTop: 3},
style,
disabled && t.atoms.text_contrast_low,
]}>
@@ -202,16 +202,18 @@ export function ItemText({children, style}: ItemTextProps) {
)
}
export function ItemIcon({icon: Comp}: ItemIconProps) {
export function ItemIcon({icon: Comp, fill}: ItemIconProps) {
const t = useTheme()
const {disabled} = useMenuItemContext()
return (
<Comp
size="lg"
fill={
disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
fill
? fill({disabled})
: disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
}
/>
)
+7 -4
View File
@@ -262,6 +262,7 @@ export function Item({children, label, onPress, style, ...rest}: ItemProps) {
a.gap_lg,
a.py_sm,
a.rounded_xs,
a.overflow_hidden,
{minHeight: 32, paddingHorizontal: 10},
web({outline: 0}),
(hovered || focused) &&
@@ -302,7 +303,7 @@ export function ItemText({children, style}: ItemTextProps) {
)
}
export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
export function ItemIcon({icon: Comp, position = 'left', fill}: ItemIconProps) {
const t = useTheme()
const {disabled} = useMenuItemContext()
return (
@@ -319,9 +320,11 @@ export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
<Comp
size="md"
fill={
disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
fill
? fill({disabled})
: disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
}
/>
</View>
+1
View File
@@ -107,6 +107,7 @@ export type ItemTextProps = React.PropsWithChildren<TextStyleProp & {}>
export type ItemIconProps = React.PropsWithChildren<{
icon: React.ComponentType<SVGIconProps>
position?: 'left' | 'right'
fill?: (props: {disabled: boolean}) => string
}>
export type GroupProps = React.PropsWithChildren<ViewStyleProp & {}>
@@ -0,0 +1,201 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isWeb} from '#/platform/detection'
import {atoms as a, useTheme, utils, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
import {
createIsEnabledCheck,
isExistingUserAsOf,
} from '#/components/dialogs/nuxs/utils'
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
import {Text} from '#/components/Typography'
import {IS_E2E} from '#/env'
export const enabled = createIsEnabledCheck(props => {
return (
!IS_E2E &&
isExistingUserAsOf(
'2026-01-16T00:00:00.000Z',
props.currentProfile.createdAt,
) &&
props.gate('live_now_beta')
)
})
export function LiveNowBetaDialog() {
const t = useTheme()
const {_} = useLingui()
const nuxDialogs = useNuxDialogContext()
const control = Dialog.useDialogControl()
Dialog.useAutoOpen(control)
const onClose = useCallback(() => {
nuxDialogs.dismissActiveNux()
}, [nuxDialogs])
return (
<Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle fill={t.palette.primary_700} />
<Dialog.ScrollableInner
label={_(msg`Show when youre live`)}
style={[web({maxWidth: 440})]}
contentContainerStyle={[
{
paddingTop: 0,
paddingLeft: 0,
paddingRight: 0,
},
]}>
<View
style={[
a.align_center,
a.overflow_hidden,
{
gap: 16,
paddingTop: isWeb ? 24 : 40,
borderTopLeftRadius: a.rounded_md.borderRadius,
borderTopRightRadius: a.rounded_md.borderRadius,
},
]}>
<LinearGradient
colors={[
t.palette.primary_100,
utils.alpha(t.palette.primary_100, 0),
]}
locations={[0, 1]}
start={{x: 0, y: 0}}
end={{x: 0, y: 1}}
style={[a.absolute, a.inset_0]}
/>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<BeakerIcon fill={t.palette.primary_700} size="sm" />
<Text
style={[
a.font_semi_bold,
{
color: t.palette.primary_700,
},
]}>
<Trans>Beta Feature</Trans>
</Text>
</View>
<View
style={[
a.relative,
a.w_full,
{
paddingTop: 8,
paddingHorizontal: 32,
paddingBottom: 32,
},
]}>
<View
style={[
{
borderRadius: 24,
aspectRatio: 326 / 105,
},
isWeb
? [
{
boxShadow: `0px 10px 15px -3px ${utils.alpha(t.palette.primary_900, 0.4)}`,
},
]
: [
t.atoms.shadow_md,
{
shadowColor: utils.alpha(t.palette.primary_900, 0.4),
shadowOpacity: 0.2,
shadowOffset: {
width: 0,
height: 10,
},
},
],
]}>
<Image
accessibilityIgnoresInvertColors
source={require('../../../../assets/images/bookmarks_announcement_nux.webp')}
style={[
a.w_full,
{
aspectRatio: 326 / 105,
},
]}
alt={_(
msg({
message: `A screenshot of a post from @rtgame.bsky.social, showing the user is currently livestreaming content on Twitch. The post reads: "New stream. New energy. Same chaos. Tap in now — we're just getting started!"`,
comment:
'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.',
}),
)}
/>
</View>
</View>
</View>
<View style={[a.align_center, a.px_xl, a.gap_2xl, a.pb_sm]}>
<View style={[a.gap_sm, a.align_center]}>
<Text
style={[
a.text_3xl,
a.leading_tight,
a.font_bold,
a.text_center,
{
fontSize: isWeb ? 28 : 32,
maxWidth: 360,
},
]}>
<Trans>Show when youre live</Trans>
</Text>
<Text
style={[
a.text_md,
a.leading_snug,
a.text_center,
{
maxWidth: 340,
},
]}>
<Trans>
Streaming on Twitch? Set your live status on Bluesky to add a
badge to your avatar. Tapping it takes people straight to your
stream.
</Trans>
</Text>
</View>
{!isWeb && (
<Button
label={_(msg`Close`)}
size="large"
color="primary"
onPress={() => {
control.close()
}}
style={[a.w_full]}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
)}
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
+6 -8
View File
@@ -20,9 +20,9 @@ import {useProfileQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell'
import {
enabled as isFindContactsAnnouncementEnabled,
FindContactsAnnouncement,
} from '#/components/dialogs/nuxs/FindContactsAnnouncement'
enabled as isLiveNowBetaDialogEnabled,
LiveNowBetaDialog,
} from '#/components/dialogs/nuxs/LiveNowBetaDialog'
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils'
import {useGeolocation} from '#/geolocation'
@@ -37,8 +37,8 @@ const queuedNuxs: {
enabled?: (props: EnabledCheckProps) => boolean
}[] = [
{
id: Nux.FindContactsAnnouncement,
enabled: isFindContactsAnnouncementEnabled,
id: Nux.LiveNowBetaDialog,
enabled: isLiveNowBetaDialogEnabled,
},
]
@@ -186,9 +186,7 @@ function Inner({
return (
<Context.Provider value={ctx}>
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
{activeNux === Nux.FindContactsAnnouncement && (
<FindContactsAnnouncement />
)}
{activeNux === Nux.LiveNowBetaDialog && <LiveNowBetaDialog />}
</Context.Provider>
)
}
+31
View File
@@ -0,0 +1,31 @@
import {View, type ViewStyle} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
/**
* The little blue dot used to nudge a user towards a certain feature. The dot
* is absolutely positioned, and is intended to be configured by passing in
* positional styles via `top`, `bottom`, `left`, and `right` props.
*/
export function Dot({
top,
bottom,
left,
right,
}: Pick<ViewStyle, 'top' | 'bottom' | 'left' | 'right'>) {
const t = useTheme()
return (
<View style={[a.absolute, {top, bottom, left, right}]}>
<View
style={[
a.rounded_full,
{
height: 8,
width: 8,
backgroundColor: t.palette.primary_500,
},
]}
/>
</View>
)
}
+19
View File
@@ -0,0 +1,19 @@
import {LinearGradient} from 'expo-linear-gradient'
import {atoms as a, useTheme, utils} from '#/alf'
export function Gradient() {
const t = useTheme()
return (
<LinearGradient
colors={[
utils.alpha(t.palette.primary_500, 0.2),
utils.alpha(t.palette.primary_500, 0.1),
]}
locations={[0, 1]}
start={{x: 0, y: 0}}
end={{x: 1, y: 0}}
style={[a.absolute, a.inset_0]}
/>
)
}
+4 -9
View File
@@ -7,7 +7,7 @@ import {
import {isAfter, parseISO} from 'date-fns'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {useLiveNowConfig} from '#/state/service-config'
import {type LiveNowConfig, useLiveNowConfig} from '#/state/service-config'
import {useTickEveryMinute} from '#/state/shell'
import type * as bsky from '#/types/bsky'
@@ -20,7 +20,7 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
tick! // revalidate every minute
if (shadowed && 'status' in shadowed && shadowed.status) {
const isValid = validateStatus(shadowed.did, shadowed.status, config)
const isValid = validateStatus(shadowed.status, config)
const isDisabled = shadowed.status.isDisabled || false
const isActive = isStatusStillActive(shadowed.status.expiresAt)
if (isValid && !isDisabled && isActive) {
@@ -65,19 +65,14 @@ export function isStatusStillActive(timeStr: string | undefined) {
}
export function validateStatus(
did: string,
status: AppBskyActorDefs.StatusView,
config: {did: string; domains: string[]}[],
config: LiveNowConfig,
) {
if (status.status !== 'app.bsky.actor.status#live') return false
const sources = config.find(cfg => cfg.did === did)
if (!sources) {
return false
}
try {
if (AppBskyEmbedExternal.isView(status.embed)) {
const url = new URL(status.embed.external.uri)
return sources.domains.includes(url.hostname)
return config.allowedDomains.includes(url.hostname)
} else {
return false
}
+1
View File
@@ -8,6 +8,7 @@ export type Gate =
| 'explore_show_suggested_feeds'
| 'feed_reply_button_open_thread'
| 'is_bsky_team_member' // special, do not remove
| 'live_now_beta'
| 'old_postonboarding'
| 'onboarding_add_video_feed'
| 'onboarding_suggested_starterpacks'
+12
View File
@@ -12,6 +12,8 @@ export enum Nux {
BookmarksAnnouncement = 'BookmarksAnnouncement',
FindContactsAnnouncement = 'FindContactsAnnouncement',
FindContactsDismissibleBanner = 'FindContactsDismissibleBanner',
LiveNowBetaDialog = 'LiveNowBetaDialog',
LiveNowBetaNudge = 'LiveNowBetaNudge',
/*
* Blocking announcements. New IDs are required for each new announcement.
@@ -62,6 +64,14 @@ export type AppNux = BaseNux<
id: Nux.FindContactsDismissibleBanner
data: undefined
}
| {
id: Nux.LiveNowBetaDialog
data: undefined
}
| {
id: Nux.LiveNowBetaNudge
data: undefined
}
>
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
@@ -75,4 +85,6 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
[Nux.BookmarksAnnouncement]: undefined,
[Nux.FindContactsAnnouncement]: undefined,
[Nux.FindContactsDismissibleBanner]: undefined,
[Nux.LiveNowBetaDialog]: undefined,
[Nux.LiveNowBetaNudge]: undefined,
}
+3 -11
View File
@@ -93,16 +93,10 @@ export function useLiveNowConfig(): LiveNowConfig {
const ctx = useContext(LiveNowContext)
const canGoLive = useCanGoLive()
const {currentAccount} = useSession()
if (!canGoLive) return {allowedDomains: []}
if (!currentAccount?.did) return {allowedDomains: []}
if (!currentAccount?.did || !canGoLive) return {allowedDomains: []}
const vip = ctx.find(live => live.did === currentAccount.did)
if (vip) {
return {
allowedDomains: vip.domains.concat(DEFAULT_LIVE_ALLOWED_DOMAINS),
}
}
return {
allowedDomains: DEFAULT_LIVE_ALLOWED_DOMAINS,
allowedDomains: DEFAULT_LIVE_ALLOWED_DOMAINS.concat(vip ? vip.domains : []),
}
}
@@ -110,9 +104,7 @@ export function useCanGoLive() {
const gate = useGate()
const {hasSession} = useSession()
if (!hasSession) return false
const isInBeta = IS_DEV ? true : gate('live_now_beta')
if (!isInBeta) return false
return true
return IS_DEV ? true : gate('live_now_beta')
}
export function useCheckEmailConfirmed() {
+1 -1
View File
@@ -954,7 +954,7 @@ let PostFeed = ({
const actor = post.author
if (
actor.status &&
validateStatus(actor.did, actor.status, liveNowConfig) &&
validateStatus(actor.status, liveNowConfig) &&
isStatusStillActive(actor.status.expiresAt)
) {
if (!seenActorWithStatusRef.current.has(actor.did)) {
+59 -18
View File
@@ -15,6 +15,7 @@ import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {type Shadow} from '#/state/cache/types'
import {useModalControls} from '#/state/modals'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {
RQKEY as profileQueryKey,
useProfileBlockMutationQueue,
@@ -25,6 +26,7 @@ import {useCanGoLive} from '#/state/service-config'
import {useSession} from '#/state/session'
import {EventStopper} from '#/view/com/util/EventStopper'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog'
@@ -59,6 +61,8 @@ import * as Prompt from '#/components/Prompt'
import {useFullVerificationState} from '#/components/verification'
import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt'
import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt'
import {Dot} from '#/features/nuxs/components/Dot'
import {Gradient} from '#/features/nuxs/components/Gradient'
import {useDevMode} from '#/storage/hooks/dev-mode'
let ProfileMenu = ({
@@ -66,6 +70,7 @@ let ProfileMenu = ({
}: {
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {currentAccount, hasSession} = useSession()
const {openModal} = useModalControls()
@@ -79,8 +84,12 @@ let ProfileMenu = ({
const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked
const [devModeEnabled] = useDevMode()
const verification = useFullVerificationState({profile})
const canGoLive = useCanGoLive(currentAccount?.did)
const canGoLive = useCanGoLive()
const status = useActorStatus(profile)
const statusNudge = useNux(Nux.LiveNowBetaNudge)
const statusNudgeActive =
canGoLive && statusNudge.status === 'ready' && !statusNudge.nux?.completed
const {mutate: saveNux} = useSaveNux()
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
@@ -229,17 +238,22 @@ let ProfileMenu = ({
<Menu.Trigger label={_(msg`More options`)}>
{({props}) => {
return (
<Button
{...props}
testID="profileHeaderDropdownBtn"
label={_(msg`More options`)}
hitSlop={HITSLOP_20}
variant="solid"
color="secondary"
size="small"
shape="round">
<ButtonIcon icon={Ellipsis} size="sm" />
</Button>
<>
<Button
{...props}
testID="profileHeaderDropdownBtn"
label={_(msg`More options`)}
hitSlop={HITSLOP_20}
variant="solid"
color="secondary"
size="small"
shape="round">
<Gradient />
<ButtonIcon icon={Ellipsis} size="sm" />
</Button>
{statusNudgeActive && <Dot top={1} right={1} />}
</>
)
}}
</Menu.Trigger>
@@ -337,11 +351,19 @@ let ProfileMenu = ({
? _(msg`Edit live status`)
: _(msg`Go live`)
}
onPress={
status.isDisabled
? goLiveDisabledDialogControl.open
: goLiveDialogControl.open
}>
onPress={() => {
if (status.isDisabled) {
goLiveDisabledDialogControl.open()
} else {
goLiveDialogControl.open()
}
saveNux({
id: Nux.LiveNowBetaNudge,
data: undefined,
completed: true,
})
}}>
<Gradient />
<Menu.ItemText>
{status.isDisabled ? (
<Trans>Go live (disabled)</Trans>
@@ -351,7 +373,26 @@ let ProfileMenu = ({
<Trans>Go live</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={LiveIcon} />
{statusNudgeActive && (
<Menu.ItemText
style={[
a.flex_0,
{
color: t.palette.primary_500,
right: isWeb ? -8 : -4,
},
]}>
<Trans>New</Trans>
</Menu.ItemText>
)}
<Menu.ItemIcon
icon={LiveIcon}
fill={
statusNudgeActive
? () => t.palette.primary_500
: undefined
}
/>
</Menu.Item>
)}
{verification.viewer.role === 'verifier' &&