[APP-1759] Live Now Open Beta (#9699)
* Add report dialog to LiveStatus dialog * Mr Worldwide™ * Bug fix bug fix * Copy update * Simplify parsing * Bump api sdk * update dev-env * Update yarn.lock * Bump api sdk * Refactor useActorStatus, add disablement and appeal dialog * Fix types * Guard against chat profile views * Go live (disabled) * Fix types * Update config and gates * Add allowed services * Merge conflicts * Fix gradient handling for nudge * Only show nudge on your own profile * Fix live avi overflow breakage * Add TODO * Feedback * Update nux image --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -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,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -167,6 +167,7 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
||||
a.gap_sm,
|
||||
a.px_md,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_low,
|
||||
@@ -193,7 +194,6 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
a.text_md,
|
||||
a.font_semi_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
{paddingTop: 3},
|
||||
style,
|
||||
disabled && t.atoms.text_contrast_low,
|
||||
]}>
|
||||
@@ -202,16 +202,18 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemIcon({icon: Comp}: ItemIconProps) {
|
||||
export function ItemIcon({icon: Comp, fill}: ItemIconProps) {
|
||||
const t = useTheme()
|
||||
const {disabled} = useMenuItemContext()
|
||||
return (
|
||||
<Comp
|
||||
size="lg"
|
||||
fill={
|
||||
disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
fill
|
||||
? fill({disabled})
|
||||
: disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -262,6 +262,7 @@ export function Item({children, label, onPress, style, ...rest}: ItemProps) {
|
||||
a.gap_lg,
|
||||
a.py_sm,
|
||||
a.rounded_xs,
|
||||
a.overflow_hidden,
|
||||
{minHeight: 32, paddingHorizontal: 10},
|
||||
web({outline: 0}),
|
||||
(hovered || focused) &&
|
||||
@@ -302,7 +303,7 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
|
||||
export function ItemIcon({icon: Comp, position = 'left', fill}: ItemIconProps) {
|
||||
const t = useTheme()
|
||||
const {disabled} = useMenuItemContext()
|
||||
return (
|
||||
@@ -319,9 +320,11 @@ export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
|
||||
<Comp
|
||||
size="md"
|
||||
fill={
|
||||
disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
fill
|
||||
? fill({disabled})
|
||||
: disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -107,6 +107,7 @@ export type ItemTextProps = React.PropsWithChildren<TextStyleProp & {}>
|
||||
export type ItemIconProps = React.PropsWithChildren<{
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
position?: 'left' | 'right'
|
||||
fill?: (props: {disabled: boolean}) => string
|
||||
}>
|
||||
|
||||
export type GroupProps = React.PropsWithChildren<ViewStyleProp & {}>
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {atoms as a, select, useTheme, utils, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
|
||||
import {
|
||||
createIsEnabledCheck,
|
||||
isExistingUserAsOf,
|
||||
} from '#/components/dialogs/nuxs/utils'
|
||||
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_E2E} from '#/env'
|
||||
|
||||
export const enabled = createIsEnabledCheck(props => {
|
||||
return (
|
||||
!IS_E2E &&
|
||||
isExistingUserAsOf(
|
||||
'2026-01-16T00:00:00.000Z',
|
||||
props.currentProfile.createdAt,
|
||||
) &&
|
||||
props.gate('live_now_beta')
|
||||
)
|
||||
})
|
||||
|
||||
export function LiveNowBetaDialog() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const nuxDialogs = useNuxDialogContext()
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
Dialog.useAutoOpen(control)
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
nuxDialogs.dismissActiveNux()
|
||||
}, [nuxDialogs])
|
||||
|
||||
const shadowColor = useMemo(() => {
|
||||
return select(t.name, {
|
||||
light: utils.alpha(t.palette.primary_900, 0.4),
|
||||
dark: utils.alpha(t.palette.primary_25, 0.4),
|
||||
dim: utils.alpha(t.palette.primary_25, 0.4),
|
||||
})
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={onClose}
|
||||
nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle fill={t.palette.primary_700} />
|
||||
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Show when you’re live`)}
|
||||
style={[web({maxWidth: 440})]}
|
||||
contentContainerStyle={[
|
||||
{
|
||||
paddingTop: 0,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.overflow_hidden,
|
||||
{
|
||||
gap: 16,
|
||||
paddingTop: isWeb ? 24 : 40,
|
||||
borderTopLeftRadius: a.rounded_md.borderRadius,
|
||||
borderTopRightRadius: a.rounded_md.borderRadius,
|
||||
},
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
t.palette.primary_100,
|
||||
utils.alpha(t.palette.primary_100, 0),
|
||||
]}
|
||||
locations={[0, 1]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 0, y: 1}}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<BeakerIcon fill={t.palette.primary_700} size="sm" />
|
||||
<Text
|
||||
style={[
|
||||
a.font_semi_bold,
|
||||
{
|
||||
color: t.palette.primary_700,
|
||||
},
|
||||
]}>
|
||||
<Trans>Beta Feature</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.w_full,
|
||||
{
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 32,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
borderRadius: 24,
|
||||
aspectRatio: 652 / 211,
|
||||
},
|
||||
isWeb
|
||||
? [
|
||||
{
|
||||
boxShadow: `0px 10px 15px -3px ${shadowColor}`,
|
||||
},
|
||||
]
|
||||
: [
|
||||
t.atoms.shadow_md,
|
||||
{
|
||||
shadowColor,
|
||||
shadowOpacity: 0.2,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
]}>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={require('../../../../assets/images/live_now_beta.webp')}
|
||||
style={[
|
||||
a.w_full,
|
||||
{
|
||||
aspectRatio: 652 / 211,
|
||||
},
|
||||
]}
|
||||
alt={_(
|
||||
msg({
|
||||
message: `A screenshot of a post from @esb.lol, showing the user is currently livestreaming content on Twitch. The post reads: "Hello! I'm live on Twitch, and I'm testing Bluesky's latest feature too!"`,
|
||||
comment:
|
||||
'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.',
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[a.align_center, a.px_xl, a.gap_2xl, a.pb_sm]}>
|
||||
<View style={[a.gap_sm, a.align_center]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_3xl,
|
||||
a.leading_tight,
|
||||
a.font_bold,
|
||||
a.text_center,
|
||||
{
|
||||
fontSize: isWeb ? 28 : 32,
|
||||
maxWidth: 360,
|
||||
},
|
||||
]}>
|
||||
<Trans>Show when you’re live</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
a.text_center,
|
||||
{
|
||||
maxWidth: 340,
|
||||
},
|
||||
]}>
|
||||
<Trans>
|
||||
Streaming on Twitch? Set your live status on Bluesky to add a
|
||||
badge to your avatar. Tapping it takes people straight to your
|
||||
stream.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{!isWeb && (
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="large"
|
||||
color="primary"
|
||||
onPress={() => {
|
||||
control.close()
|
||||
}}
|
||||
style={[a.w_full]}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -20,9 +20,9 @@ import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {type SessionAccount, useSession} from '#/state/session'
|
||||
import {useOnboardingState} from '#/state/shell'
|
||||
import {
|
||||
enabled as isFindContactsAnnouncementEnabled,
|
||||
FindContactsAnnouncement,
|
||||
} from '#/components/dialogs/nuxs/FindContactsAnnouncement'
|
||||
enabled as isLiveNowBetaDialogEnabled,
|
||||
LiveNowBetaDialog,
|
||||
} from '#/components/dialogs/nuxs/LiveNowBetaDialog'
|
||||
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
|
||||
import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils'
|
||||
import {useGeolocation} from '#/geolocation'
|
||||
@@ -37,8 +37,8 @@ const queuedNuxs: {
|
||||
enabled?: (props: EnabledCheckProps) => boolean
|
||||
}[] = [
|
||||
{
|
||||
id: Nux.FindContactsAnnouncement,
|
||||
enabled: isFindContactsAnnouncementEnabled,
|
||||
id: Nux.LiveNowBetaDialog,
|
||||
enabled: isLiveNowBetaDialogEnabled,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -186,9 +186,7 @@ function Inner({
|
||||
return (
|
||||
<Context.Provider value={ctx}>
|
||||
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
|
||||
{activeNux === Nux.FindContactsAnnouncement && (
|
||||
<FindContactsAnnouncement />
|
||||
)}
|
||||
{activeNux === Nux.LiveNowBetaDialog && <LiveNowBetaDialog />}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {LinkPreview} from './LinkPreview'
|
||||
@@ -172,26 +171,13 @@ function DialogInner({
|
||||
</TextField.Root>
|
||||
</View>
|
||||
{(liveLinkError || linkMetaError) && (
|
||||
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
|
||||
<WarningIcon
|
||||
style={[{color: t.palette.negative_500}]}
|
||||
size="sm"
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
a.flex_1,
|
||||
a.font_semi_bold,
|
||||
{color: t.palette.negative_500},
|
||||
]}>
|
||||
{liveLinkError ? (
|
||||
<Trans>This is not a valid link</Trans>
|
||||
) : (
|
||||
cleanError(linkMetaError)
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
<Admonition type="error">
|
||||
{liveLinkError ? (
|
||||
<Trans>This is not a valid link</Trans>
|
||||
) : (
|
||||
cleanError(linkMetaError)
|
||||
)}
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
<LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} />
|
||||
|
||||
@@ -6,13 +6,14 @@ import {useLingui} from '@lingui/react'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {definitelyUrl} from '#/lib/strings/url-helpers'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useLiveNowConfig} from '#/state/service-config'
|
||||
import {useTickEveryMinute} from '#/state/shell'
|
||||
import {atoms as a, ios, native, platform, useTheme, web} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import {getLiveServiceNames} from '#/components/live/utils'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import * as Select from '#/components/Select'
|
||||
@@ -49,6 +50,10 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
|
||||
const [duration, setDuration] = useState(60)
|
||||
const moderationOpts = useModerationOpts()
|
||||
const tick = useTickEveryMinute()
|
||||
const liveNowConfig = useLiveNowConfig()
|
||||
const {formatted: allowedServices} = getLiveServiceNames(
|
||||
liveNowConfig.allowedDomains,
|
||||
)
|
||||
|
||||
const time = useCallback(
|
||||
(offset: number) => {
|
||||
@@ -139,27 +144,21 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
{(liveLinkError || linkMetaError) && (
|
||||
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
|
||||
<WarningIcon
|
||||
style={[{color: t.palette.negative_500}]}
|
||||
size="sm"
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
a.flex_1,
|
||||
a.font_semi_bold,
|
||||
{color: t.palette.negative_500},
|
||||
]}>
|
||||
{liveLinkError ? (
|
||||
<Trans>This is not a valid link</Trans>
|
||||
) : (
|
||||
cleanError(linkMetaError)
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
{liveLinkError || linkMetaError ? (
|
||||
<Admonition type="error">
|
||||
{liveLinkError ? (
|
||||
<Trans>This is not a valid link</Trans>
|
||||
) : (
|
||||
cleanError(linkMetaError)
|
||||
)}
|
||||
</Admonition>
|
||||
) : (
|
||||
<Admonition type="tip">
|
||||
<Trans>
|
||||
The following services are enabled for your account:{' '}
|
||||
{allowedServices}
|
||||
</Trans>
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
<LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} />
|
||||
|
||||
@@ -18,10 +18,10 @@ import {useLiveNowConfig} from '#/state/service-config'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {useDialogContext} from '#/components/Dialog'
|
||||
import {getLiveServiceNames} from '#/components/live/utils'
|
||||
|
||||
export function useLiveLinkMetaQuery(url: string | null) {
|
||||
const liveNowConfig = useLiveNowConfig()
|
||||
const {currentAccount} = useSession()
|
||||
const {_} = useLingui()
|
||||
|
||||
const agent = useAgent()
|
||||
@@ -30,13 +30,14 @@ export function useLiveLinkMetaQuery(url: string | null) {
|
||||
queryKey: ['link-meta', url],
|
||||
queryFn: async () => {
|
||||
if (!url) return undefined
|
||||
const config = liveNowConfig.find(cfg => cfg.did === currentAccount?.did)
|
||||
|
||||
if (!config) throw new Error(_(msg`You are not allowed to go live`))
|
||||
|
||||
const urlp = new URL(url)
|
||||
if (!config.domains.includes(urlp.hostname)) {
|
||||
throw new Error(_(msg`${urlp.hostname} is not a valid URL`))
|
||||
if (!liveNowConfig.allowedDomains.has(urlp.hostname)) {
|
||||
const {formatted} = getLiveServiceNames(liveNowConfig.allowedDomains)
|
||||
throw new Error(
|
||||
_(
|
||||
msg`This service is not supported while the Live feature is in beta. Allowed services: ${formatted}.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return await getLinkMeta(agent, url)
|
||||
|
||||
@@ -35,3 +35,28 @@ export function useDebouncedValue<T>(val: T, delayMs: number): T {
|
||||
|
||||
return prev
|
||||
}
|
||||
|
||||
const serviceUrlToNameMap: Record<string, string> = {
|
||||
'twitch.tv': 'Twitch',
|
||||
'www.twitch.tv': 'Twitch',
|
||||
'youtube.com': 'YouTube',
|
||||
'www.youtube.com': 'YouTube',
|
||||
'youtu.be': 'YouTube',
|
||||
'nba.com': 'NBA',
|
||||
'www.nba.com': 'NBA',
|
||||
'nba.smart.link': 'nba.smart.link',
|
||||
'espn.com': 'ESPN',
|
||||
'www.espn.com': 'ESPN',
|
||||
'stream.place': 'Streamplace',
|
||||
'skylight.social': 'Skylight',
|
||||
}
|
||||
|
||||
export function getLiveServiceNames(domains: Set<string>) {
|
||||
const names = Array.from(
|
||||
new Set(Array.from(domains.values()).map(d => serviceUrlToNameMap[d] || d)),
|
||||
)
|
||||
return {
|
||||
names,
|
||||
formatted: names.join(', '),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import {View, type ViewStyle} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
/**
|
||||
* The little blue dot used to nudge a user towards a certain feature. The dot
|
||||
* is absolutely positioned, and is intended to be configured by passing in
|
||||
* positional styles via `top`, `bottom`, `left`, and `right` props.
|
||||
*/
|
||||
export function Dot({
|
||||
top,
|
||||
bottom,
|
||||
left,
|
||||
right,
|
||||
}: Pick<ViewStyle, 'top' | 'bottom' | 'left' | 'right'>) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.absolute, {top, bottom, left, right}]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
{
|
||||
height: 8,
|
||||
width: 8,
|
||||
backgroundColor: t.palette.primary_500,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
|
||||
import {atoms as a, useTheme, utils, type ViewStyleProp} from '#/alf'
|
||||
|
||||
/**
|
||||
* A gradient overlay using the primary color at low opacity. This component is
|
||||
* absolutely positioned and intended to be composed within other components,
|
||||
* with optional styling allowed, such as adjusting border radius.
|
||||
*/
|
||||
export function Gradient({style}: ViewStyleProp) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={[
|
||||
utils.alpha(t.palette.primary_500, 0.2),
|
||||
utils.alpha(t.palette.primary_500, 0.1),
|
||||
]}
|
||||
locations={[0, 1]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 0}}
|
||||
style={[a.absolute, a.inset_0, style]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import {isAfter, parseISO} from 'date-fns'
|
||||
|
||||
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useLiveNowConfig} from '#/state/service-config'
|
||||
import {type LiveNowConfig, useLiveNowConfig} from '#/state/service-config'
|
||||
import {useTickEveryMinute} from '#/state/shell'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
@@ -20,7 +20,7 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
|
||||
tick! // revalidate every minute
|
||||
|
||||
if (shadowed && 'status' in shadowed && shadowed.status) {
|
||||
const isValid = validateStatus(shadowed.did, shadowed.status, config)
|
||||
const isValid = validateStatus(shadowed.status, config)
|
||||
const isDisabled = shadowed.status.isDisabled || false
|
||||
const isActive = isStatusStillActive(shadowed.status.expiresAt)
|
||||
if (isValid && !isDisabled && isActive) {
|
||||
@@ -65,19 +65,14 @@ export function isStatusStillActive(timeStr: string | undefined) {
|
||||
}
|
||||
|
||||
export function validateStatus(
|
||||
did: string,
|
||||
status: AppBskyActorDefs.StatusView,
|
||||
config: {did: string; domains: string[]}[],
|
||||
config: LiveNowConfig,
|
||||
) {
|
||||
if (status.status !== 'app.bsky.actor.status#live') return false
|
||||
const sources = config.find(cfg => cfg.did === did)
|
||||
if (!sources) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
if (AppBskyEmbedExternal.isView(status.embed)) {
|
||||
const url = new URL(status.embed.external.uri)
|
||||
return sources.domains.includes(url.hostname)
|
||||
return config.allowedDomains.has(url.hostname)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {createContext, useContext, useMemo} from 'react'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {useLanguagePrefs} from '#/state/preferences/languages'
|
||||
import {useServiceConfigQuery} from '#/state/queries/service-config'
|
||||
import {useSession} from '#/state/session'
|
||||
import {IS_DEV} from '#/env'
|
||||
import {device} from '#/storage'
|
||||
|
||||
type TrendingContext = {
|
||||
@@ -18,7 +21,7 @@ const TrendingContext = createContext<TrendingContext>({
|
||||
})
|
||||
TrendingContext.displayName = 'TrendingContext'
|
||||
|
||||
const LiveNowContext = createContext<LiveNowContext | null>(null)
|
||||
const LiveNowContext = createContext<LiveNowContext>([])
|
||||
LiveNowContext.displayName = 'LiveNowContext'
|
||||
|
||||
const CheckEmailConfirmedContext = createContext<boolean | null>(null)
|
||||
@@ -82,19 +85,28 @@ export function useTrendingConfig() {
|
||||
return useContext(TrendingContext)
|
||||
}
|
||||
|
||||
export function useLiveNowConfig() {
|
||||
const DEFAULT_LIVE_ALLOWED_DOMAINS = ['twitch.tv', 'www.twitch.tv']
|
||||
export type LiveNowConfig = {
|
||||
allowedDomains: Set<string>
|
||||
}
|
||||
export function useLiveNowConfig(): LiveNowConfig {
|
||||
const ctx = useContext(LiveNowContext)
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'useLiveNowConfig must be used within a ServiceConfigManager',
|
||||
)
|
||||
const canGoLive = useCanGoLive()
|
||||
const {currentAccount} = useSession()
|
||||
if (!currentAccount?.did || !canGoLive) return {allowedDomains: new Set()}
|
||||
const vip = ctx.find(live => live.did === currentAccount.did)
|
||||
return {
|
||||
allowedDomains: new Set(
|
||||
DEFAULT_LIVE_ALLOWED_DOMAINS.concat(vip ? vip.domains : []),
|
||||
),
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function useCanGoLive(did?: string) {
|
||||
const config = useLiveNowConfig()
|
||||
return !!config.find(cfg => cfg.did === did)
|
||||
export function useCanGoLive() {
|
||||
const gate = useGate()
|
||||
const {hasSession} = useSession()
|
||||
if (!hasSession) return false
|
||||
return IS_DEV ? true : gate('live_now_beta')
|
||||
}
|
||||
|
||||
export function useCheckEmailConfirmed() {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,15 @@ let ProfileMenu = ({
|
||||
const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked
|
||||
const [devModeEnabled] = useDevMode()
|
||||
const verification = useFullVerificationState({profile})
|
||||
const canGoLive = useCanGoLive(currentAccount?.did)
|
||||
const canGoLive = useCanGoLive()
|
||||
const status = useActorStatus(profile)
|
||||
const statusNudge = useNux(Nux.LiveNowBetaNudge)
|
||||
const statusNudgeActive =
|
||||
isSelf &&
|
||||
canGoLive &&
|
||||
statusNudge.status === 'ready' &&
|
||||
!statusNudge.nux?.completed
|
||||
const {mutate: saveNux} = useSaveNux()
|
||||
|
||||
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
|
||||
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
|
||||
@@ -229,17 +241,22 @@ let ProfileMenu = ({
|
||||
<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">
|
||||
{statusNudgeActive && <Gradient style={[a.rounded_full]} />}
|
||||
<ButtonIcon icon={Ellipsis} size="sm" />
|
||||
</Button>
|
||||
|
||||
{statusNudgeActive && <Dot top={1} right={1} />}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Menu.Trigger>
|
||||
@@ -337,11 +354,19 @@ let ProfileMenu = ({
|
||||
? _(msg`Edit live status`)
|
||||
: _(msg`Go live`)
|
||||
}
|
||||
onPress={
|
||||
status.isDisabled
|
||||
? goLiveDisabledDialogControl.open
|
||||
: goLiveDialogControl.open
|
||||
}>
|
||||
onPress={() => {
|
||||
if (status.isDisabled) {
|
||||
goLiveDisabledDialogControl.open()
|
||||
} else {
|
||||
goLiveDialogControl.open()
|
||||
}
|
||||
saveNux({
|
||||
id: Nux.LiveNowBetaNudge,
|
||||
data: undefined,
|
||||
completed: true,
|
||||
})
|
||||
}}>
|
||||
{statusNudgeActive && <Gradient />}
|
||||
<Menu.ItemText>
|
||||
{status.isDisabled ? (
|
||||
<Trans>Go live (disabled)</Trans>
|
||||
@@ -351,7 +376,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' &&
|
||||
|
||||
Reference in New Issue
Block a user