[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:
@@ -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(', '),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user