Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

[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>

authored by

Eric Bailey
Samuel Newman
and committed by
GitHub
2223babc 5922c662

+456 -107
assets/images/live_now_beta.webp

This is a binary file and will not be displayed.

+8 -2
src/components/Dialog/index.tsx
··· 359 359 ) 360 360 } 361 361 362 - export function Handle({difference = false}: {difference?: boolean}) { 362 + export function Handle({ 363 + difference = false, 364 + fill, 365 + }: { 366 + difference?: boolean 367 + fill?: string 368 + }) { 363 369 const t = useTheme() 364 370 const {_} = useLingui() 365 371 const {screenReaderEnabled} = useA11y() ··· 390 396 opacity: 0.75, 391 397 } 392 398 : { 393 - backgroundColor: t.palette.contrast_975, 399 + backgroundColor: fill || t.palette.contrast_975, 394 400 opacity: 0.5, 395 401 }, 396 402 ]}
+7 -5
src/components/Menu/index.tsx
··· 167 167 a.gap_sm, 168 168 a.px_md, 169 169 a.rounded_md, 170 + a.overflow_hidden, 170 171 a.border, 171 172 t.atoms.bg_contrast_25, 172 173 t.atoms.border_contrast_low, ··· 193 194 a.text_md, 194 195 a.font_semi_bold, 195 196 t.atoms.text_contrast_high, 196 - {paddingTop: 3}, 197 197 style, 198 198 disabled && t.atoms.text_contrast_low, 199 199 ]}> ··· 202 202 ) 203 203 } 204 204 205 - export function ItemIcon({icon: Comp}: ItemIconProps) { 205 + export function ItemIcon({icon: Comp, fill}: ItemIconProps) { 206 206 const t = useTheme() 207 207 const {disabled} = useMenuItemContext() 208 208 return ( 209 209 <Comp 210 210 size="lg" 211 211 fill={ 212 - disabled 213 - ? t.atoms.text_contrast_low.color 214 - : t.atoms.text_contrast_medium.color 212 + fill 213 + ? fill({disabled}) 214 + : disabled 215 + ? t.atoms.text_contrast_low.color 216 + : t.atoms.text_contrast_medium.color 215 217 } 216 218 /> 217 219 )
+7 -4
src/components/Menu/index.web.tsx
··· 262 262 a.gap_lg, 263 263 a.py_sm, 264 264 a.rounded_xs, 265 + a.overflow_hidden, 265 266 {minHeight: 32, paddingHorizontal: 10}, 266 267 web({outline: 0}), 267 268 (hovered || focused) && ··· 302 303 ) 303 304 } 304 305 305 - export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) { 306 + export function ItemIcon({icon: Comp, position = 'left', fill}: ItemIconProps) { 306 307 const t = useTheme() 307 308 const {disabled} = useMenuItemContext() 308 309 return ( ··· 319 320 <Comp 320 321 size="md" 321 322 fill={ 322 - disabled 323 - ? t.atoms.text_contrast_low.color 324 - : t.atoms.text_contrast_medium.color 323 + fill 324 + ? fill({disabled}) 325 + : disabled 326 + ? t.atoms.text_contrast_low.color 327 + : t.atoms.text_contrast_medium.color 325 328 } 326 329 /> 327 330 </View>
+1
src/components/Menu/types.ts
··· 107 107 export type ItemIconProps = React.PropsWithChildren<{ 108 108 icon: React.ComponentType<SVGIconProps> 109 109 position?: 'left' | 'right' 110 + fill?: (props: {disabled: boolean}) => string 110 111 }> 111 112 112 113 export type GroupProps = React.PropsWithChildren<ViewStyleProp & {}>
+209
src/components/dialogs/nuxs/LiveNowBetaDialog.tsx
··· 1 + import {useCallback, useMemo} from 'react' 2 + import {View} from 'react-native' 3 + import {Image} from 'expo-image' 4 + import {LinearGradient} from 'expo-linear-gradient' 5 + import {msg, Trans} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + 8 + import {isWeb} from '#/platform/detection' 9 + import {atoms as a, select, useTheme, utils, web} from '#/alf' 10 + import {Button, ButtonText} from '#/components/Button' 11 + import * as Dialog from '#/components/Dialog' 12 + import {useNuxDialogContext} from '#/components/dialogs/nuxs' 13 + import { 14 + createIsEnabledCheck, 15 + isExistingUserAsOf, 16 + } from '#/components/dialogs/nuxs/utils' 17 + import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker' 18 + import {Text} from '#/components/Typography' 19 + import {IS_E2E} from '#/env' 20 + 21 + export const enabled = createIsEnabledCheck(props => { 22 + return ( 23 + !IS_E2E && 24 + isExistingUserAsOf( 25 + '2026-01-16T00:00:00.000Z', 26 + props.currentProfile.createdAt, 27 + ) && 28 + props.gate('live_now_beta') 29 + ) 30 + }) 31 + 32 + export function LiveNowBetaDialog() { 33 + const t = useTheme() 34 + const {_} = useLingui() 35 + const nuxDialogs = useNuxDialogContext() 36 + const control = Dialog.useDialogControl() 37 + 38 + Dialog.useAutoOpen(control) 39 + 40 + const onClose = useCallback(() => { 41 + nuxDialogs.dismissActiveNux() 42 + }, [nuxDialogs]) 43 + 44 + const shadowColor = useMemo(() => { 45 + return select(t.name, { 46 + light: utils.alpha(t.palette.primary_900, 0.4), 47 + dark: utils.alpha(t.palette.primary_25, 0.4), 48 + dim: utils.alpha(t.palette.primary_25, 0.4), 49 + }) 50 + }, [t]) 51 + 52 + return ( 53 + <Dialog.Outer 54 + control={control} 55 + onClose={onClose} 56 + nativeOptions={{preventExpansion: true}}> 57 + <Dialog.Handle fill={t.palette.primary_700} /> 58 + 59 + <Dialog.ScrollableInner 60 + label={_(msg`Show when you’re live`)} 61 + style={[web({maxWidth: 440})]} 62 + contentContainerStyle={[ 63 + { 64 + paddingTop: 0, 65 + paddingLeft: 0, 66 + paddingRight: 0, 67 + }, 68 + ]}> 69 + <View 70 + style={[ 71 + a.align_center, 72 + a.overflow_hidden, 73 + { 74 + gap: 16, 75 + paddingTop: isWeb ? 24 : 40, 76 + borderTopLeftRadius: a.rounded_md.borderRadius, 77 + borderTopRightRadius: a.rounded_md.borderRadius, 78 + }, 79 + ]}> 80 + <LinearGradient 81 + colors={[ 82 + t.palette.primary_100, 83 + utils.alpha(t.palette.primary_100, 0), 84 + ]} 85 + locations={[0, 1]} 86 + start={{x: 0, y: 0}} 87 + end={{x: 0, y: 1}} 88 + style={[a.absolute, a.inset_0]} 89 + /> 90 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 91 + <BeakerIcon fill={t.palette.primary_700} size="sm" /> 92 + <Text 93 + style={[ 94 + a.font_semi_bold, 95 + { 96 + color: t.palette.primary_700, 97 + }, 98 + ]}> 99 + <Trans>Beta Feature</Trans> 100 + </Text> 101 + </View> 102 + 103 + <View 104 + style={[ 105 + a.relative, 106 + a.w_full, 107 + { 108 + paddingTop: 8, 109 + paddingHorizontal: 32, 110 + paddingBottom: 32, 111 + }, 112 + ]}> 113 + <View 114 + style={[ 115 + { 116 + borderRadius: 24, 117 + aspectRatio: 652 / 211, 118 + }, 119 + isWeb 120 + ? [ 121 + { 122 + boxShadow: `0px 10px 15px -3px ${shadowColor}`, 123 + }, 124 + ] 125 + : [ 126 + t.atoms.shadow_md, 127 + { 128 + shadowColor, 129 + shadowOpacity: 0.2, 130 + shadowOffset: { 131 + width: 0, 132 + height: 10, 133 + }, 134 + }, 135 + ], 136 + ]}> 137 + <Image 138 + accessibilityIgnoresInvertColors 139 + source={require('../../../../assets/images/live_now_beta.webp')} 140 + style={[ 141 + a.w_full, 142 + { 143 + aspectRatio: 652 / 211, 144 + }, 145 + ]} 146 + alt={_( 147 + msg({ 148 + 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!"`, 149 + comment: 150 + '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.', 151 + }), 152 + )} 153 + /> 154 + </View> 155 + </View> 156 + </View> 157 + <View style={[a.align_center, a.px_xl, a.gap_2xl, a.pb_sm]}> 158 + <View style={[a.gap_sm, a.align_center]}> 159 + <Text 160 + style={[ 161 + a.text_3xl, 162 + a.leading_tight, 163 + a.font_bold, 164 + a.text_center, 165 + { 166 + fontSize: isWeb ? 28 : 32, 167 + maxWidth: 360, 168 + }, 169 + ]}> 170 + <Trans>Show when you’re live</Trans> 171 + </Text> 172 + <Text 173 + style={[ 174 + a.text_md, 175 + a.leading_snug, 176 + a.text_center, 177 + { 178 + maxWidth: 340, 179 + }, 180 + ]}> 181 + <Trans> 182 + Streaming on Twitch? Set your live status on Bluesky to add a 183 + badge to your avatar. Tapping it takes people straight to your 184 + stream. 185 + </Trans> 186 + </Text> 187 + </View> 188 + 189 + {!isWeb && ( 190 + <Button 191 + label={_(msg`Close`)} 192 + size="large" 193 + color="primary" 194 + onPress={() => { 195 + control.close() 196 + }} 197 + style={[a.w_full]}> 198 + <ButtonText> 199 + <Trans>Close</Trans> 200 + </ButtonText> 201 + </Button> 202 + )} 203 + </View> 204 + 205 + <Dialog.Close /> 206 + </Dialog.ScrollableInner> 207 + </Dialog.Outer> 208 + ) 209 + }
+6 -8
src/components/dialogs/nuxs/index.tsx
··· 20 20 import {type SessionAccount, useSession} from '#/state/session' 21 21 import {useOnboardingState} from '#/state/shell' 22 22 import { 23 - enabled as isFindContactsAnnouncementEnabled, 24 - FindContactsAnnouncement, 25 - } from '#/components/dialogs/nuxs/FindContactsAnnouncement' 23 + enabled as isLiveNowBetaDialogEnabled, 24 + LiveNowBetaDialog, 25 + } from '#/components/dialogs/nuxs/LiveNowBetaDialog' 26 26 import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' 27 27 import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils' 28 28 import {useGeolocation} from '#/geolocation' ··· 37 37 enabled?: (props: EnabledCheckProps) => boolean 38 38 }[] = [ 39 39 { 40 - id: Nux.FindContactsAnnouncement, 41 - enabled: isFindContactsAnnouncementEnabled, 40 + id: Nux.LiveNowBetaDialog, 41 + enabled: isLiveNowBetaDialogEnabled, 42 42 }, 43 43 ] 44 44 ··· 186 186 return ( 187 187 <Context.Provider value={ctx}> 188 188 {/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/} 189 - {activeNux === Nux.FindContactsAnnouncement && ( 190 - <FindContactsAnnouncement /> 191 - )} 189 + {activeNux === Nux.LiveNowBetaDialog && <LiveNowBetaDialog />} 192 190 </Context.Provider> 193 191 ) 194 192 }
+7 -21
src/components/live/EditLiveDialog.tsx
··· 18 18 import * as Dialog from '#/components/Dialog' 19 19 import * as TextField from '#/components/forms/TextField' 20 20 import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock' 21 - import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 22 21 import {Loader} from '#/components/Loader' 23 22 import {Text} from '#/components/Typography' 24 23 import {LinkPreview} from './LinkPreview' ··· 172 171 </TextField.Root> 173 172 </View> 174 173 {(liveLinkError || linkMetaError) && ( 175 - <View style={[a.flex_row, a.gap_xs, a.align_center]}> 176 - <WarningIcon 177 - style={[{color: t.palette.negative_500}]} 178 - size="sm" 179 - /> 180 - <Text 181 - style={[ 182 - a.text_sm, 183 - a.leading_snug, 184 - a.flex_1, 185 - a.font_semi_bold, 186 - {color: t.palette.negative_500}, 187 - ]}> 188 - {liveLinkError ? ( 189 - <Trans>This is not a valid link</Trans> 190 - ) : ( 191 - cleanError(linkMetaError) 192 - )} 193 - </Text> 194 - </View> 174 + <Admonition type="error"> 175 + {liveLinkError ? ( 176 + <Trans>This is not a valid link</Trans> 177 + ) : ( 178 + cleanError(linkMetaError) 179 + )} 180 + </Admonition> 195 181 )} 196 182 197 183 <LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} />
+21 -22
src/components/live/GoLiveDialog.tsx
··· 6 6 import {cleanError} from '#/lib/strings/errors' 7 7 import {definitelyUrl} from '#/lib/strings/url-helpers' 8 8 import {useModerationOpts} from '#/state/preferences/moderation-opts' 9 + import {useLiveNowConfig} from '#/state/service-config' 9 10 import {useTickEveryMinute} from '#/state/shell' 10 11 import {atoms as a, ios, native, platform, useTheme, web} from '#/alf' 11 12 import {Admonition} from '#/components/Admonition' 12 13 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 13 14 import * as Dialog from '#/components/Dialog' 14 15 import * as TextField from '#/components/forms/TextField' 15 - import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 16 + import {getLiveServiceNames} from '#/components/live/utils' 16 17 import {Loader} from '#/components/Loader' 17 18 import * as ProfileCard from '#/components/ProfileCard' 18 19 import * as Select from '#/components/Select' ··· 49 50 const [duration, setDuration] = useState(60) 50 51 const moderationOpts = useModerationOpts() 51 52 const tick = useTickEveryMinute() 53 + const liveNowConfig = useLiveNowConfig() 54 + const {formatted: allowedServices} = getLiveServiceNames( 55 + liveNowConfig.allowedDomains, 56 + ) 52 57 53 58 const time = useCallback( 54 59 (offset: number) => { ··· 139 144 /> 140 145 </TextField.Root> 141 146 </View> 142 - {(liveLinkError || linkMetaError) && ( 143 - <View style={[a.flex_row, a.gap_xs, a.align_center]}> 144 - <WarningIcon 145 - style={[{color: t.palette.negative_500}]} 146 - size="sm" 147 - /> 148 - <Text 149 - style={[ 150 - a.text_sm, 151 - a.leading_snug, 152 - a.flex_1, 153 - a.font_semi_bold, 154 - {color: t.palette.negative_500}, 155 - ]}> 156 - {liveLinkError ? ( 157 - <Trans>This is not a valid link</Trans> 158 - ) : ( 159 - cleanError(linkMetaError) 160 - )} 161 - </Text> 162 - </View> 147 + {liveLinkError || linkMetaError ? ( 148 + <Admonition type="error"> 149 + {liveLinkError ? ( 150 + <Trans>This is not a valid link</Trans> 151 + ) : ( 152 + cleanError(linkMetaError) 153 + )} 154 + </Admonition> 155 + ) : ( 156 + <Admonition type="tip"> 157 + <Trans> 158 + The following services are enabled for your account:{' '} 159 + {allowedServices} 160 + </Trans> 161 + </Admonition> 163 162 )} 164 163 165 164 <LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} />
+8 -7
src/components/live/queries.ts
··· 18 18 import {useAgent, useSession} from '#/state/session' 19 19 import * as Toast from '#/view/com/util/Toast' 20 20 import {useDialogContext} from '#/components/Dialog' 21 + import {getLiveServiceNames} from '#/components/live/utils' 21 22 22 23 export function useLiveLinkMetaQuery(url: string | null) { 23 24 const liveNowConfig = useLiveNowConfig() 24 - const {currentAccount} = useSession() 25 25 const {_} = useLingui() 26 26 27 27 const agent = useAgent() ··· 30 30 queryKey: ['link-meta', url], 31 31 queryFn: async () => { 32 32 if (!url) return undefined 33 - const config = liveNowConfig.find(cfg => cfg.did === currentAccount?.did) 34 - 35 - if (!config) throw new Error(_(msg`You are not allowed to go live`)) 36 - 37 33 const urlp = new URL(url) 38 - if (!config.domains.includes(urlp.hostname)) { 39 - throw new Error(_(msg`${urlp.hostname} is not a valid URL`)) 34 + if (!liveNowConfig.allowedDomains.has(urlp.hostname)) { 35 + const {formatted} = getLiveServiceNames(liveNowConfig.allowedDomains) 36 + throw new Error( 37 + _( 38 + msg`This service is not supported while the Live feature is in beta. Allowed services: ${formatted}.`, 39 + ), 40 + ) 40 41 } 41 42 42 43 return await getLinkMeta(agent, url)
+25
src/components/live/utils.ts
··· 35 35 36 36 return prev 37 37 } 38 + 39 + const serviceUrlToNameMap: Record<string, string> = { 40 + 'twitch.tv': 'Twitch', 41 + 'www.twitch.tv': 'Twitch', 42 + 'youtube.com': 'YouTube', 43 + 'www.youtube.com': 'YouTube', 44 + 'youtu.be': 'YouTube', 45 + 'nba.com': 'NBA', 46 + 'www.nba.com': 'NBA', 47 + 'nba.smart.link': 'nba.smart.link', 48 + 'espn.com': 'ESPN', 49 + 'www.espn.com': 'ESPN', 50 + 'stream.place': 'Streamplace', 51 + 'skylight.social': 'Skylight', 52 + } 53 + 54 + export function getLiveServiceNames(domains: Set<string>) { 55 + const names = Array.from( 56 + new Set(Array.from(domains.values()).map(d => serviceUrlToNameMap[d] || d)), 57 + ) 58 + return { 59 + names, 60 + formatted: names.join(', '), 61 + } 62 + }
+31
src/features/nuxs/components/Dot.tsx
··· 1 + import {View, type ViewStyle} from 'react-native' 2 + 3 + import {atoms as a, useTheme} from '#/alf' 4 + 5 + /** 6 + * The little blue dot used to nudge a user towards a certain feature. The dot 7 + * is absolutely positioned, and is intended to be configured by passing in 8 + * positional styles via `top`, `bottom`, `left`, and `right` props. 9 + */ 10 + export function Dot({ 11 + top, 12 + bottom, 13 + left, 14 + right, 15 + }: Pick<ViewStyle, 'top' | 'bottom' | 'left' | 'right'>) { 16 + const t = useTheme() 17 + return ( 18 + <View style={[a.absolute, {top, bottom, left, right}]}> 19 + <View 20 + style={[ 21 + a.rounded_full, 22 + { 23 + height: 8, 24 + width: 8, 25 + backgroundColor: t.palette.primary_500, 26 + }, 27 + ]} 28 + /> 29 + </View> 30 + ) 31 + }
+24
src/features/nuxs/components/Gradient.tsx
··· 1 + import {LinearGradient} from 'expo-linear-gradient' 2 + 3 + import {atoms as a, useTheme, utils, type ViewStyleProp} from '#/alf' 4 + 5 + /** 6 + * A gradient overlay using the primary color at low opacity. This component is 7 + * absolutely positioned and intended to be composed within other components, 8 + * with optional styling allowed, such as adjusting border radius. 9 + */ 10 + export function Gradient({style}: ViewStyleProp) { 11 + const t = useTheme() 12 + return ( 13 + <LinearGradient 14 + colors={[ 15 + utils.alpha(t.palette.primary_500, 0.2), 16 + utils.alpha(t.palette.primary_500, 0.1), 17 + ]} 18 + locations={[0, 1]} 19 + start={{x: 0, y: 0}} 20 + end={{x: 1, y: 0}} 21 + style={[a.absolute, a.inset_0, style]} 22 + /> 23 + ) 24 + }
+4 -9
src/lib/actor-status.ts
··· 7 7 import {isAfter, parseISO} from 'date-fns' 8 8 9 9 import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' 10 - import {useLiveNowConfig} from '#/state/service-config' 10 + import {type LiveNowConfig, useLiveNowConfig} from '#/state/service-config' 11 11 import {useTickEveryMinute} from '#/state/shell' 12 12 import type * as bsky from '#/types/bsky' 13 13 ··· 20 20 tick! // revalidate every minute 21 21 22 22 if (shadowed && 'status' in shadowed && shadowed.status) { 23 - const isValid = validateStatus(shadowed.did, shadowed.status, config) 23 + const isValid = validateStatus(shadowed.status, config) 24 24 const isDisabled = shadowed.status.isDisabled || false 25 25 const isActive = isStatusStillActive(shadowed.status.expiresAt) 26 26 if (isValid && !isDisabled && isActive) { ··· 65 65 } 66 66 67 67 export function validateStatus( 68 - did: string, 69 68 status: AppBskyActorDefs.StatusView, 70 - config: {did: string; domains: string[]}[], 69 + config: LiveNowConfig, 71 70 ) { 72 71 if (status.status !== 'app.bsky.actor.status#live') return false 73 - const sources = config.find(cfg => cfg.did === did) 74 - if (!sources) { 75 - return false 76 - } 77 72 try { 78 73 if (AppBskyEmbedExternal.isView(status.embed)) { 79 74 const url = new URL(status.embed.external.uri) 80 - return sources.domains.includes(url.hostname) 75 + return config.allowedDomains.has(url.hostname) 81 76 } else { 82 77 return false 83 78 }
+1
src/lib/statsig/gates.ts
··· 8 8 | 'explore_show_suggested_feeds' 9 9 | 'feed_reply_button_open_thread' 10 10 | 'is_bsky_team_member' // special, do not remove 11 + | 'live_now_beta' 11 12 | 'old_postonboarding' 12 13 | 'onboarding_add_video_feed' 13 14 | 'onboarding_suggested_starterpacks'
+12
src/state/queries/nuxs/definitions.ts
··· 12 12 BookmarksAnnouncement = 'BookmarksAnnouncement', 13 13 FindContactsAnnouncement = 'FindContactsAnnouncement', 14 14 FindContactsDismissibleBanner = 'FindContactsDismissibleBanner', 15 + LiveNowBetaDialog = 'LiveNowBetaDialog', 16 + LiveNowBetaNudge = 'LiveNowBetaNudge', 15 17 16 18 /* 17 19 * Blocking announcements. New IDs are required for each new announcement. ··· 62 64 id: Nux.FindContactsDismissibleBanner 63 65 data: undefined 64 66 } 67 + | { 68 + id: Nux.LiveNowBetaDialog 69 + data: undefined 70 + } 71 + | { 72 + id: Nux.LiveNowBetaNudge 73 + data: undefined 74 + } 65 75 > 66 76 67 77 export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = { ··· 75 85 [Nux.BookmarksAnnouncement]: undefined, 76 86 [Nux.FindContactsAnnouncement]: undefined, 77 87 [Nux.FindContactsDismissibleBanner]: undefined, 88 + [Nux.LiveNowBetaDialog]: undefined, 89 + [Nux.LiveNowBetaNudge]: undefined, 78 90 }
+22 -10
src/state/service-config.tsx
··· 1 1 import {createContext, useContext, useMemo} from 'react' 2 2 3 + import {useGate} from '#/lib/statsig/statsig' 3 4 import {useLanguagePrefs} from '#/state/preferences/languages' 4 5 import {useServiceConfigQuery} from '#/state/queries/service-config' 6 + import {useSession} from '#/state/session' 7 + import {IS_DEV} from '#/env' 5 8 import {device} from '#/storage' 6 9 7 10 type TrendingContext = { ··· 18 21 }) 19 22 TrendingContext.displayName = 'TrendingContext' 20 23 21 - const LiveNowContext = createContext<LiveNowContext | null>(null) 24 + const LiveNowContext = createContext<LiveNowContext>([]) 22 25 LiveNowContext.displayName = 'LiveNowContext' 23 26 24 27 const CheckEmailConfirmedContext = createContext<boolean | null>(null) ··· 82 85 return useContext(TrendingContext) 83 86 } 84 87 85 - export function useLiveNowConfig() { 88 + const DEFAULT_LIVE_ALLOWED_DOMAINS = ['twitch.tv', 'www.twitch.tv'] 89 + export type LiveNowConfig = { 90 + allowedDomains: Set<string> 91 + } 92 + export function useLiveNowConfig(): LiveNowConfig { 86 93 const ctx = useContext(LiveNowContext) 87 - if (!ctx) { 88 - throw new Error( 89 - 'useLiveNowConfig must be used within a ServiceConfigManager', 90 - ) 94 + const canGoLive = useCanGoLive() 95 + const {currentAccount} = useSession() 96 + if (!currentAccount?.did || !canGoLive) return {allowedDomains: new Set()} 97 + const vip = ctx.find(live => live.did === currentAccount.did) 98 + return { 99 + allowedDomains: new Set( 100 + DEFAULT_LIVE_ALLOWED_DOMAINS.concat(vip ? vip.domains : []), 101 + ), 91 102 } 92 - return ctx 93 103 } 94 104 95 - export function useCanGoLive(did?: string) { 96 - const config = useLiveNowConfig() 97 - return !!config.find(cfg => cfg.did === did) 105 + export function useCanGoLive() { 106 + const gate = useGate() 107 + const {hasSession} = useSession() 108 + if (!hasSession) return false 109 + return IS_DEV ? true : gate('live_now_beta') 98 110 } 99 111 100 112 export function useCheckEmailConfirmed() {
+1 -1
src/view/com/posts/PostFeed.tsx
··· 954 954 const actor = post.author 955 955 if ( 956 956 actor.status && 957 - validateStatus(actor.did, actor.status, liveNowConfig) && 957 + validateStatus(actor.status, liveNowConfig) && 958 958 isStatusStillActive(actor.status.expiresAt) 959 959 ) { 960 960 if (!seenActorWithStatusRef.current.has(actor.did)) {
+62 -18
src/view/com/profile/ProfileMenu.tsx
··· 15 15 import {isWeb} from '#/platform/detection' 16 16 import {type Shadow} from '#/state/cache/types' 17 17 import {useModalControls} from '#/state/modals' 18 + import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 18 19 import { 19 20 RQKEY as profileQueryKey, 20 21 useProfileBlockMutationQueue, ··· 25 26 import {useSession} from '#/state/session' 26 27 import {EventStopper} from '#/view/com/util/EventStopper' 27 28 import * as Toast from '#/view/com/util/Toast' 29 + import {atoms as a, useTheme} from '#/alf' 28 30 import {Button, ButtonIcon} from '#/components/Button' 29 31 import {useDialogControl} from '#/components/Dialog' 30 32 import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog' ··· 59 61 import {useFullVerificationState} from '#/components/verification' 60 62 import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt' 61 63 import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' 64 + import {Dot} from '#/features/nuxs/components/Dot' 65 + import {Gradient} from '#/features/nuxs/components/Gradient' 62 66 import {useDevMode} from '#/storage/hooks/dev-mode' 63 67 64 68 let ProfileMenu = ({ ··· 66 70 }: { 67 71 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 68 72 }): React.ReactNode => { 73 + const t = useTheme() 69 74 const {_} = useLingui() 70 75 const {currentAccount, hasSession} = useSession() 71 76 const {openModal} = useModalControls() ··· 79 84 const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked 80 85 const [devModeEnabled] = useDevMode() 81 86 const verification = useFullVerificationState({profile}) 82 - const canGoLive = useCanGoLive(currentAccount?.did) 87 + const canGoLive = useCanGoLive() 83 88 const status = useActorStatus(profile) 89 + const statusNudge = useNux(Nux.LiveNowBetaNudge) 90 + const statusNudgeActive = 91 + isSelf && 92 + canGoLive && 93 + statusNudge.status === 'ready' && 94 + !statusNudge.nux?.completed 95 + const {mutate: saveNux} = useSaveNux() 84 96 85 97 const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) 86 98 const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) ··· 229 241 <Menu.Trigger label={_(msg`More options`)}> 230 242 {({props}) => { 231 243 return ( 232 - <Button 233 - {...props} 234 - testID="profileHeaderDropdownBtn" 235 - label={_(msg`More options`)} 236 - hitSlop={HITSLOP_20} 237 - variant="solid" 238 - color="secondary" 239 - size="small" 240 - shape="round"> 241 - <ButtonIcon icon={Ellipsis} size="sm" /> 242 - </Button> 244 + <> 245 + <Button 246 + {...props} 247 + testID="profileHeaderDropdownBtn" 248 + label={_(msg`More options`)} 249 + hitSlop={HITSLOP_20} 250 + variant="solid" 251 + color="secondary" 252 + size="small" 253 + shape="round"> 254 + {statusNudgeActive && <Gradient style={[a.rounded_full]} />} 255 + <ButtonIcon icon={Ellipsis} size="sm" /> 256 + </Button> 257 + 258 + {statusNudgeActive && <Dot top={1} right={1} />} 259 + </> 243 260 ) 244 261 }} 245 262 </Menu.Trigger> ··· 337 354 ? _(msg`Edit live status`) 338 355 : _(msg`Go live`) 339 356 } 340 - onPress={ 341 - status.isDisabled 342 - ? goLiveDisabledDialogControl.open 343 - : goLiveDialogControl.open 344 - }> 357 + onPress={() => { 358 + if (status.isDisabled) { 359 + goLiveDisabledDialogControl.open() 360 + } else { 361 + goLiveDialogControl.open() 362 + } 363 + saveNux({ 364 + id: Nux.LiveNowBetaNudge, 365 + data: undefined, 366 + completed: true, 367 + }) 368 + }}> 369 + {statusNudgeActive && <Gradient />} 345 370 <Menu.ItemText> 346 371 {status.isDisabled ? ( 347 372 <Trans>Go live (disabled)</Trans> ··· 351 376 <Trans>Go live</Trans> 352 377 )} 353 378 </Menu.ItemText> 354 - <Menu.ItemIcon icon={LiveIcon} /> 379 + {statusNudgeActive && ( 380 + <Menu.ItemText 381 + style={[ 382 + a.flex_0, 383 + { 384 + color: t.palette.primary_500, 385 + right: isWeb ? -8 : -4, 386 + }, 387 + ]}> 388 + <Trans>New</Trans> 389 + </Menu.ItemText> 390 + )} 391 + <Menu.ItemIcon 392 + icon={LiveIcon} 393 + fill={ 394 + statusNudgeActive 395 + ? () => t.palette.primary_500 396 + : undefined 397 + } 398 + /> 355 399 </Menu.Item> 356 400 )} 357 401 {verification.viewer.role === 'verifier' &&