Bluesky app fork with some witchin' additions 💫

[APP-1776] Refactor Live Now config, reorg files (#9871)

authored by

Eric Bailey and committed by
GitHub
cc2255cc 8ee9709f

+626 -497
+3
.env.example
··· 46 47 # live-events web worker URL 48 LIVE_EVENTS_DEV_URL=
··· 46 47 # live-events web worker URL 48 LIVE_EVENTS_DEV_URL= 49 + 50 + # app-config web worker URL 51 + APP_CONFIG_DEV_URL=
+39 -32
src/App.native.tsx
··· 22 import I18nProvider from '#/locale/i18nProvider' 23 import {logger} from '#/logger' 24 import {Provider as A11yProvider} from '#/state/a11y' 25 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 26 import {Provider as DialogStateProvider} from '#/state/dialogs' 27 import {Provider as EmailVerificationProvider} from '#/state/email-verification' ··· 103 Geo.resolve() 104 prefetchAgeAssuranceConfig() 105 prefetchLiveEvents() 106 107 function InnerApp() { 108 const [isReady, setIsReady] = React.useState(false) ··· 228 */ 229 return ( 230 <Geo.Provider> 231 - <A11yProvider> 232 - <KeyboardControllerProvider> 233 - <OnboardingProvider> 234 - <AnalyticsContext> 235 - <SessionProvider> 236 - <PrefsStateProvider> 237 - <I18nProvider> 238 - <ShellStateProvider> 239 - <ModalStateProvider> 240 - <DialogStateProvider> 241 - <LightboxStateProvider> 242 - <PortalProvider> 243 - <BottomSheetProvider> 244 - <StarterPackProvider> 245 - <SafeAreaProvider 246 - initialMetrics={initialWindowMetrics}> 247 - <InnerApp /> 248 - </SafeAreaProvider> 249 - </StarterPackProvider> 250 - </BottomSheetProvider> 251 - </PortalProvider> 252 - </LightboxStateProvider> 253 - </DialogStateProvider> 254 - </ModalStateProvider> 255 - </ShellStateProvider> 256 - </I18nProvider> 257 - </PrefsStateProvider> 258 - </SessionProvider> 259 - </AnalyticsContext> 260 - </OnboardingProvider> 261 - </KeyboardControllerProvider> 262 - </A11yProvider> 263 </Geo.Provider> 264 ) 265 }
··· 22 import I18nProvider from '#/locale/i18nProvider' 23 import {logger} from '#/logger' 24 import {Provider as A11yProvider} from '#/state/a11y' 25 + import { 26 + prefetchAppConfig, 27 + Provider as AppConfigProvider, 28 + } from '#/state/appConfig' 29 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 30 import {Provider as DialogStateProvider} from '#/state/dialogs' 31 import {Provider as EmailVerificationProvider} from '#/state/email-verification' ··· 107 Geo.resolve() 108 prefetchAgeAssuranceConfig() 109 prefetchLiveEvents() 110 + prefetchAppConfig() 111 112 function InnerApp() { 113 const [isReady, setIsReady] = React.useState(false) ··· 233 */ 234 return ( 235 <Geo.Provider> 236 + <AppConfigProvider> 237 + <A11yProvider> 238 + <KeyboardControllerProvider> 239 + <OnboardingProvider> 240 + <AnalyticsContext> 241 + <SessionProvider> 242 + <PrefsStateProvider> 243 + <I18nProvider> 244 + <ShellStateProvider> 245 + <ModalStateProvider> 246 + <DialogStateProvider> 247 + <LightboxStateProvider> 248 + <PortalProvider> 249 + <BottomSheetProvider> 250 + <StarterPackProvider> 251 + <SafeAreaProvider 252 + initialMetrics={initialWindowMetrics}> 253 + <InnerApp /> 254 + </SafeAreaProvider> 255 + </StarterPackProvider> 256 + </BottomSheetProvider> 257 + </PortalProvider> 258 + </LightboxStateProvider> 259 + </DialogStateProvider> 260 + </ModalStateProvider> 261 + </ShellStateProvider> 262 + </I18nProvider> 263 + </PrefsStateProvider> 264 + </SessionProvider> 265 + </AnalyticsContext> 266 + </OnboardingProvider> 267 + </KeyboardControllerProvider> 268 + </A11yProvider> 269 + </AppConfigProvider> 270 </Geo.Provider> 271 ) 272 }
+32 -25
src/App.web.tsx
··· 13 import I18nProvider from '#/locale/i18nProvider' 14 import {logger} from '#/logger' 15 import {Provider as A11yProvider} from '#/state/a11y' 16 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 17 import {Provider as DialogStateProvider} from '#/state/dialogs' 18 import {Provider as EmailVerificationProvider} from '#/state/email-verification' ··· 79 Geo.resolve() 80 prefetchAgeAssuranceConfig() 81 prefetchLiveEvents() 82 83 function InnerApp() { 84 const [isReady, setIsReady] = useState(false) ··· 202 */ 203 return ( 204 <Geo.Provider> 205 - <A11yProvider> 206 - <OnboardingProvider> 207 - <AnalyticsContext> 208 - <SessionProvider> 209 - <PrefsStateProvider> 210 - <I18nProvider> 211 - <ShellStateProvider> 212 - <ModalStateProvider> 213 - <DialogStateProvider> 214 - <LightboxStateProvider> 215 - <PortalProvider> 216 - <StarterPackProvider> 217 - <InnerApp /> 218 - </StarterPackProvider> 219 - </PortalProvider> 220 - </LightboxStateProvider> 221 - </DialogStateProvider> 222 - </ModalStateProvider> 223 - </ShellStateProvider> 224 - </I18nProvider> 225 - </PrefsStateProvider> 226 - </SessionProvider> 227 - </AnalyticsContext> 228 - </OnboardingProvider> 229 - </A11yProvider> 230 </Geo.Provider> 231 ) 232 }
··· 13 import I18nProvider from '#/locale/i18nProvider' 14 import {logger} from '#/logger' 15 import {Provider as A11yProvider} from '#/state/a11y' 16 + import { 17 + prefetchAppConfig, 18 + Provider as AppConfigProvider, 19 + } from '#/state/appConfig' 20 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 21 import {Provider as DialogStateProvider} from '#/state/dialogs' 22 import {Provider as EmailVerificationProvider} from '#/state/email-verification' ··· 83 Geo.resolve() 84 prefetchAgeAssuranceConfig() 85 prefetchLiveEvents() 86 + prefetchAppConfig() 87 88 function InnerApp() { 89 const [isReady, setIsReady] = useState(false) ··· 207 */ 208 return ( 209 <Geo.Provider> 210 + <AppConfigProvider> 211 + <A11yProvider> 212 + <OnboardingProvider> 213 + <AnalyticsContext> 214 + <SessionProvider> 215 + <PrefsStateProvider> 216 + <I18nProvider> 217 + <ShellStateProvider> 218 + <ModalStateProvider> 219 + <DialogStateProvider> 220 + <LightboxStateProvider> 221 + <PortalProvider> 222 + <StarterPackProvider> 223 + <InnerApp /> 224 + </StarterPackProvider> 225 + </PortalProvider> 226 + </LightboxStateProvider> 227 + </DialogStateProvider> 228 + </ModalStateProvider> 229 + </ShellStateProvider> 230 + </I18nProvider> 231 + </PrefsStateProvider> 232 + </SessionProvider> 233 + </AnalyticsContext> 234 + </OnboardingProvider> 235 + </A11yProvider> 236 + </AppConfigProvider> 237 </Geo.Provider> 238 ) 239 }
+1 -1
src/components/AccountList.tsx
··· 4 import {msg, Trans} from '@lingui/macro' 5 import {useLingui} from '@lingui/react' 6 7 - import {useActorStatus} from '#/lib/actor-status' 8 import {isJwtExpired} from '#/lib/jwt' 9 import {sanitizeDisplayName} from '#/lib/strings/display-names' 10 import {sanitizeHandle} from '#/lib/strings/handles' ··· 19 import {Text} from '#/components/Typography' 20 import {useSimpleVerificationState} from '#/components/verification' 21 import {VerificationCheck} from '#/components/verification/VerificationCheck' 22 23 export function AccountList({ 24 onSelectAccount,
··· 4 import {msg, Trans} from '@lingui/macro' 5 import {useLingui} from '@lingui/react' 6 7 import {isJwtExpired} from '#/lib/jwt' 8 import {sanitizeDisplayName} from '#/lib/strings/display-names' 9 import {sanitizeHandle} from '#/lib/strings/handles' ··· 18 import {Text} from '#/components/Typography' 19 import {useSimpleVerificationState} from '#/components/verification' 20 import {VerificationCheck} from '#/components/verification/VerificationCheck' 21 + import {useActorStatus} from '#/features/liveNow' 22 23 export function AccountList({ 24 onSelectAccount,
+1 -1
src/components/ProfileCard.tsx
··· 14 import {msg} from '@lingui/macro' 15 import {useLingui} from '@lingui/react' 16 17 - import {useActorStatus} from '#/lib/actor-status' 18 import {getModerationCauseKey} from '#/lib/moderation' 19 import {forceLTR} from '#/lib/strings/bidi' 20 import {NON_BREAKING_SPACE} from '#/lib/strings/constants' ··· 47 import {useSimpleVerificationState} from '#/components/verification' 48 import {VerificationCheck} from '#/components/verification/VerificationCheck' 49 import {type Metrics} from '#/analytics' 50 import type * as bsky from '#/types/bsky' 51 52 export function Default({
··· 14 import {msg} from '@lingui/macro' 15 import {useLingui} from '@lingui/react' 16 17 import {getModerationCauseKey} from '#/lib/moderation' 18 import {forceLTR} from '#/lib/strings/bidi' 19 import {NON_BREAKING_SPACE} from '#/lib/strings/constants' ··· 46 import {useSimpleVerificationState} from '#/components/verification' 47 import {VerificationCheck} from '#/components/verification/VerificationCheck' 48 import {type Metrics} from '#/analytics' 49 + import {useActorStatus} from '#/features/liveNow' 50 import type * as bsky from '#/types/bsky' 51 52 export function Default({
+2 -2
src/components/ProfileHoverCard/index.web.tsx
··· 10 import {useLingui} from '@lingui/react' 11 import {useNavigation} from '@react-navigation/native' 12 13 - import {useActorStatus} from '#/lib/actor-status' 14 import {getModerationCauseKey} from '#/lib/moderation' 15 import {makeProfileLink} from '#/lib/routes/links' 16 import {type NavigationProp} from '#/lib/routes/types' ··· 34 shouldShowKnownFollowers, 35 } from '#/components/KnownFollowers' 36 import {InlineLinkText, Link} from '#/components/Link' 37 - import {LiveStatus} from '#/components/live/LiveStatusDialog' 38 import {Loader} from '#/components/Loader' 39 import * as Pills from '#/components/Pills' 40 import {Portal} from '#/components/Portal' ··· 43 import {useSimpleVerificationState} from '#/components/verification' 44 import {VerificationCheck} from '#/components/verification/VerificationCheck' 45 import {IS_WEB_TOUCH_DEVICE} from '#/env' 46 import {type ProfileHoverCardProps} from './types' 47 48 const floatingMiddlewares = [
··· 10 import {useLingui} from '@lingui/react' 11 import {useNavigation} from '@react-navigation/native' 12 13 import {getModerationCauseKey} from '#/lib/moderation' 14 import {makeProfileLink} from '#/lib/routes/links' 15 import {type NavigationProp} from '#/lib/routes/types' ··· 33 shouldShowKnownFollowers, 34 } from '#/components/KnownFollowers' 35 import {InlineLinkText, Link} from '#/components/Link' 36 import {Loader} from '#/components/Loader' 37 import * as Pills from '#/components/Pills' 38 import {Portal} from '#/components/Portal' ··· 41 import {useSimpleVerificationState} from '#/components/verification' 42 import {VerificationCheck} from '#/components/verification/VerificationCheck' 43 import {IS_WEB_TOUCH_DEVICE} from '#/env' 44 + import {useActorStatus} from '#/features/liveNow' 45 + import {LiveStatus} from '#/features/liveNow/components/LiveStatusDialog' 46 import {type ProfileHoverCardProps} from './types' 47 48 const floatingMiddlewares = [
+4 -3
src/components/live/EditLiveDialog.tsx src/features/liveNow/components/EditLiveDialog.tsx
··· 9 import {useLingui} from '@lingui/react' 10 import {differenceInMinutes} from 'date-fns' 11 12 import {cleanError} from '#/lib/strings/errors' 13 import {definitelyUrl} from '#/lib/strings/url-helpers' 14 import {useTickEveryMinute} from '#/state/shell' ··· 20 import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock' 21 import {Loader} from '#/components/Loader' 22 import {Text} from '#/components/Typography' 23 - import {LinkPreview} from './LinkPreview' 24 import { 25 useLiveLinkMetaQuery, 26 useRemoveLiveStatusMutation, 27 useUpsertLiveStatusMutation, 28 - } from './queries' 29 - import {displayDuration, useDebouncedValue} from './utils' 30 31 export function EditLiveDialog({ 32 control,
··· 9 import {useLingui} from '@lingui/react' 10 import {differenceInMinutes} from 'date-fns' 11 12 + import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue' 13 import {cleanError} from '#/lib/strings/errors' 14 import {definitelyUrl} from '#/lib/strings/url-helpers' 15 import {useTickEveryMinute} from '#/state/shell' ··· 21 import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock' 22 import {Loader} from '#/components/Loader' 23 import {Text} from '#/components/Typography' 24 import { 25 + displayDuration, 26 useLiveLinkMetaQuery, 27 useRemoveLiveStatusMutation, 28 useUpsertLiveStatusMutation, 29 + } from '#/features/liveNow' 30 + import {LinkPreview} from '#/features/liveNow/components/LinkPreview' 31 32 export function EditLiveDialog({ 33 control,
+8 -7
src/components/live/GoLiveDialog.tsx src/features/liveNow/components/GoLiveDialog.tsx
··· 3 import {msg, Trans} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 6 import {cleanError} from '#/lib/strings/errors' 7 import {definitelyUrl} from '#/lib/strings/url-helpers' 8 import {useModerationOpts} from '#/state/preferences/moderation-opts' 9 - import {useLiveNowConfig} from '#/state/service-config' 10 import {useTickEveryMinute} from '#/state/shell' 11 import {atoms as a, ios, native, platform, useTheme, web} from '#/alf' 12 import {Admonition} from '#/components/Admonition' 13 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 14 import * as Dialog from '#/components/Dialog' 15 import * as TextField from '#/components/forms/TextField' 16 - import { 17 - displayDuration, 18 - getLiveServiceNames, 19 - useDebouncedValue, 20 - } from '#/components/live/utils' 21 import {Loader} from '#/components/Loader' 22 import * as ProfileCard from '#/components/ProfileCard' 23 import * as Select from '#/components/Select' 24 import {Text} from '#/components/Typography' 25 import type * as bsky from '#/types/bsky' 26 import {LinkPreview} from './LinkPreview' 27 - import {useLiveLinkMetaQuery, useUpsertLiveStatusMutation} from './queries' 28 29 export function GoLiveDialog({ 30 control,
··· 3 import {msg, Trans} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 6 + import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue' 7 import {cleanError} from '#/lib/strings/errors' 8 import {definitelyUrl} from '#/lib/strings/url-helpers' 9 import {useModerationOpts} from '#/state/preferences/moderation-opts' 10 import {useTickEveryMinute} from '#/state/shell' 11 import {atoms as a, ios, native, platform, useTheme, web} from '#/alf' 12 import {Admonition} from '#/components/Admonition' 13 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 14 import * as Dialog from '#/components/Dialog' 15 import * as TextField from '#/components/forms/TextField' 16 import {Loader} from '#/components/Loader' 17 import * as ProfileCard from '#/components/ProfileCard' 18 import * as Select from '#/components/Select' 19 import {Text} from '#/components/Typography' 20 + import { 21 + displayDuration, 22 + getLiveServiceNames, 23 + useLiveLinkMetaQuery, 24 + useLiveNowConfig, 25 + useUpsertLiveStatusMutation, 26 + } from '#/features/liveNow' 27 import type * as bsky from '#/types/bsky' 28 import {LinkPreview} from './LinkPreview' 29 30 export function GoLiveDialog({ 31 control,
src/components/live/GoLiveDisabledDialog.tsx src/features/liveNow/components/GoLiveDisabledDialog.tsx
src/components/live/LinkPreview.tsx src/features/liveNow/components/LinkPreview.tsx
src/components/live/LiveIndicator.tsx src/features/liveNow/components/LiveIndicator.tsx
+3 -3
src/components/live/LiveStatusDialog.tsx src/features/liveNow/components/LiveStatusDialog.tsx
··· 17 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 18 import * as Dialog from '#/components/Dialog' 19 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' 20 import {createStaticClick, SimpleInlineLinkText} from '#/components/Link' 21 import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog' 22 import * as ProfileCard from '#/components/ProfileCard' 23 import {Text} from '#/components/Typography' 24 import {useAnalytics} from '#/analytics' 25 import type * as bsky from '#/types/bsky' 26 - import {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe' 27 - import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight' 28 - import {LiveIndicator} from './LiveIndicator' 29 30 export function LiveStatusDialog({ 31 control,
··· 17 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 18 import * as Dialog from '#/components/Dialog' 19 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' 20 + import {Globe_Stroke2_Corner0_Rounded} from '#/components/icons/Globe' 21 + import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '#/components/icons/SquareArrowTopRight' 22 import {createStaticClick, SimpleInlineLinkText} from '#/components/Link' 23 import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog' 24 import * as ProfileCard from '#/components/ProfileCard' 25 import {Text} from '#/components/Typography' 26 import {useAnalytics} from '#/analytics' 27 + import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator' 28 import type * as bsky from '#/types/bsky' 29 30 export function LiveStatusDialog({ 31 control,
-218
src/components/live/queries.ts
··· 1 - import { 2 - type $Typed, 3 - type AppBskyActorStatus, 4 - type AppBskyEmbedExternal, 5 - ComAtprotoRepoPutRecord, 6 - } from '@atproto/api' 7 - import {retry} from '@atproto/common-web' 8 - import {msg} from '@lingui/macro' 9 - import {useLingui} from '@lingui/react' 10 - import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 11 - 12 - import {uploadBlob} from '#/lib/api' 13 - import {imageToThumb} from '#/lib/api/resolve' 14 - import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' 15 - import {updateProfileShadow} from '#/state/cache/profile-shadow' 16 - import {useLiveNowConfig} from '#/state/service-config' 17 - import {useAgent, useSession} from '#/state/session' 18 - import * as Toast from '#/view/com/util/Toast' 19 - import {useDialogContext} from '#/components/Dialog' 20 - import {getLiveServiceNames} from '#/components/live/utils' 21 - import {useAnalytics} from '#/analytics' 22 - 23 - export function useLiveLinkMetaQuery(url: string | null) { 24 - const liveNowConfig = useLiveNowConfig() 25 - const {_} = useLingui() 26 - 27 - const agent = useAgent() 28 - return useQuery({ 29 - enabled: !!url, 30 - queryKey: ['link-meta', url], 31 - queryFn: async () => { 32 - if (!url) return undefined 33 - const urlp = new URL(url) 34 - if (!liveNowConfig.currentAccountAllowedHosts.has(urlp.hostname)) { 35 - const {formatted} = getLiveServiceNames( 36 - liveNowConfig.currentAccountAllowedHosts, 37 - ) 38 - throw new Error( 39 - _( 40 - msg`This service is not supported while the Live feature is in beta. Allowed services: ${formatted}.`, 41 - ), 42 - ) 43 - } 44 - 45 - return await getLinkMeta(agent, url) 46 - }, 47 - }) 48 - } 49 - 50 - export function useUpsertLiveStatusMutation( 51 - duration: number, 52 - linkMeta: LinkMeta | null | undefined, 53 - createdAt?: string, 54 - ) { 55 - const ax = useAnalytics() 56 - const {currentAccount} = useSession() 57 - const agent = useAgent() 58 - const queryClient = useQueryClient() 59 - const control = useDialogContext() 60 - const {_} = useLingui() 61 - 62 - return useMutation({ 63 - mutationFn: async () => { 64 - if (!currentAccount) throw new Error('Not logged in') 65 - 66 - let embed: $Typed<AppBskyEmbedExternal.Main> | undefined 67 - 68 - if (linkMeta) { 69 - let thumb 70 - 71 - if (linkMeta.image) { 72 - try { 73 - const img = await imageToThumb(linkMeta.image) 74 - if (img) { 75 - const blob = await uploadBlob( 76 - agent, 77 - img.source.path, 78 - img.source.mime, 79 - ) 80 - thumb = blob.data.blob 81 - } 82 - } catch (e: any) { 83 - ax.logger.error(`Failed to upload thumbnail for live status`, { 84 - url: linkMeta.url, 85 - image: linkMeta.image, 86 - safeMessage: e, 87 - }) 88 - } 89 - } 90 - 91 - embed = { 92 - $type: 'app.bsky.embed.external', 93 - external: { 94 - $type: 'app.bsky.embed.external#external', 95 - title: linkMeta.title ?? '', 96 - description: linkMeta.description ?? '', 97 - uri: linkMeta.url, 98 - thumb, 99 - }, 100 - } 101 - } 102 - 103 - const record = { 104 - $type: 'app.bsky.actor.status', 105 - createdAt: createdAt ?? new Date().toISOString(), 106 - status: 'app.bsky.actor.status#live', 107 - durationMinutes: duration, 108 - embed, 109 - } satisfies AppBskyActorStatus.Record 110 - 111 - const upsert = async () => { 112 - const repo = currentAccount.did 113 - const collection = 'app.bsky.actor.status' 114 - 115 - const existing = await agent.com.atproto.repo 116 - .getRecord({repo, collection, rkey: 'self'}) 117 - .catch(_e => undefined) 118 - 119 - await agent.com.atproto.repo.putRecord({ 120 - repo, 121 - collection, 122 - rkey: 'self', 123 - record, 124 - swapRecord: existing?.data.cid || null, 125 - }) 126 - } 127 - 128 - await retry(upsert, { 129 - maxRetries: 5, 130 - retryable: e => e instanceof ComAtprotoRepoPutRecord.InvalidSwapError, 131 - }) 132 - 133 - return { 134 - record, 135 - image: linkMeta?.image, 136 - } 137 - }, 138 - onError: (e: any) => { 139 - ax.logger.error(`Failed to upsert live status`, { 140 - url: linkMeta?.url, 141 - image: linkMeta?.image, 142 - safeMessage: e, 143 - }) 144 - }, 145 - onSuccess: ({record, image}) => { 146 - if (createdAt) { 147 - ax.metric('live:edit', {duration: record.durationMinutes}) 148 - } else { 149 - ax.metric('live:create', {duration: record.durationMinutes}) 150 - } 151 - 152 - Toast.show(_(msg`You are now live!`)) 153 - control.close(() => { 154 - if (!currentAccount) return 155 - 156 - const expiresAt = new Date(record.createdAt) 157 - expiresAt.setMinutes(expiresAt.getMinutes() + record.durationMinutes) 158 - 159 - updateProfileShadow(queryClient, currentAccount.did, { 160 - status: { 161 - $type: 'app.bsky.actor.defs#statusView', 162 - status: 'app.bsky.actor.status#live', 163 - isActive: true, 164 - expiresAt: expiresAt.toISOString(), 165 - embed: 166 - record.embed && image 167 - ? { 168 - $type: 'app.bsky.embed.external#view', 169 - external: { 170 - ...record.embed.external, 171 - $type: 'app.bsky.embed.external#viewExternal', 172 - thumb: image, 173 - }, 174 - } 175 - : undefined, 176 - record, 177 - }, 178 - }) 179 - }) 180 - }, 181 - }) 182 - } 183 - 184 - export function useRemoveLiveStatusMutation() { 185 - const ax = useAnalytics() 186 - const {currentAccount} = useSession() 187 - const agent = useAgent() 188 - const queryClient = useQueryClient() 189 - const control = useDialogContext() 190 - const {_} = useLingui() 191 - 192 - return useMutation({ 193 - mutationFn: async () => { 194 - if (!currentAccount) throw new Error('Not logged in') 195 - 196 - await agent.app.bsky.actor.status.delete({ 197 - repo: currentAccount.did, 198 - rkey: 'self', 199 - }) 200 - }, 201 - onError: (e: any) => { 202 - ax.logger.error(`Failed to remove live status`, { 203 - safeMessage: e, 204 - }) 205 - }, 206 - onSuccess: () => { 207 - ax.metric('live:remove', {}) 208 - Toast.show(_(msg`You are no longer live`)) 209 - control.close(() => { 210 - if (!currentAccount) return 211 - 212 - updateProfileShadow(queryClient, currentAccount.did, { 213 - status: undefined, 214 - }) 215 - }) 216 - }, 217 - }) 218 - }
···
+28 -20
src/components/live/utils.ts src/features/liveNow/utils.ts
··· 1 - import {useEffect, useState} from 'react' 2 import {type I18n} from '@lingui/core' 3 import {plural} from '@lingui/macro' 4 5 export function displayDuration(i18n: I18n, durationInMinutes: number) { 6 const roundedDurationInMinutes = Math.round(durationInMinutes) ··· 24 : minutesString 25 } 26 27 - // Trailing debounce 28 - export function useDebouncedValue<T>(val: T, delayMs: number): T { 29 - const [prev, setPrev] = useState(val) 30 - 31 - useEffect(() => { 32 - const timeout = setTimeout(() => setPrev(val), delayMs) 33 - return () => clearTimeout(timeout) 34 - }, [val, delayMs]) 35 - 36 - return prev 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 'bluecast.app': 'Bluecast', 53 - 'www.bluecast.app': 'Bluecast', 54 } 55 56 export function getLiveServiceNames(domains: Set<string>) { 57 const names = Array.from( 58 - new Set(Array.from(domains.values()).map(d => serviceUrlToNameMap[d] || d)), 59 ) 60 return { 61 names, 62 formatted: names.join(', '), 63 } 64 }
··· 1 import {type I18n} from '@lingui/core' 2 import {plural} from '@lingui/macro' 3 + import psl from 'psl' 4 5 export function displayDuration(i18n: I18n, durationInMinutes: number) { 6 const roundedDurationInMinutes = Math.round(durationInMinutes) ··· 24 : minutesString 25 } 26 27 const serviceUrlToNameMap: Record<string, string> = { 28 'twitch.tv': 'Twitch', 29 'youtube.com': 'YouTube', 30 'nba.com': 'NBA', 31 'nba.smart.link': 'nba.smart.link', 32 'espn.com': 'ESPN', 33 'stream.place': 'Streamplace', 34 'skylight.social': 'Skylight', 35 'bluecast.app': 'Bluecast', 36 } 37 38 export function getLiveServiceNames(domains: Set<string>) { 39 const names = Array.from( 40 + new Set( 41 + Array.from(domains.values()) 42 + .map(d => sanitizeLiveNowHost(d)) 43 + .map(d => serviceUrlToNameMap[d] || d), 44 + ), 45 ) 46 return { 47 names, 48 formatted: names.join(', '), 49 } 50 } 51 + 52 + export function sanitizeLiveNowHost(hostname: string) { 53 + // special case this one 54 + if (hostname === 'nba.smart.link') { 55 + return hostname 56 + } 57 + const parsed = psl.parse(hostname) 58 + if (parsed.error || !parsed.listed || !parsed.domain) { 59 + // fall back to dumb version 60 + return hostname.replace(/^www\./, '') 61 + } 62 + return parsed.domain 63 + } 64 + 65 + /** 66 + * Extracts the apex domain from a given URL, for use when matching allowed 67 + * Live Now hosts. 68 + */ 69 + export function getLiveNowHost(url: string) { 70 + const {hostname} = new URL(url) 71 + return sanitizeLiveNowHost(hostname) 72 + }
+10
src/env/common.ts
··· 141 export const LIVE_EVENTS_URL = IS_DEV 142 ? (LIVE_EVENTS_DEV_URL ?? LIVE_EVENTS_PROD_URL) 143 : LIVE_EVENTS_PROD_URL
··· 141 export const LIVE_EVENTS_URL = IS_DEV 142 ? (LIVE_EVENTS_DEV_URL ?? LIVE_EVENTS_PROD_URL) 143 : LIVE_EVENTS_PROD_URL 144 + 145 + /** 146 + * URLs for the app-config web worker. Can be a 147 + * locally running server, see `env.example` for more. 148 + */ 149 + export const APP_CONFIG_DEV_URL = process.env.APP_CONFIG_DEV_URL 150 + export const APP_CONFIG_PROD_URL = `https://app-config.workers.bsky.app` 151 + export const APP_CONFIG_URL = IS_DEV 152 + ? (APP_CONFIG_DEV_URL ?? APP_CONFIG_PROD_URL) 153 + : APP_CONFIG_PROD_URL
+360
src/features/liveNow/index.tsx
···
··· 1 + import {useMemo} from 'react' 2 + import { 3 + type $Typed, 4 + type AppBskyActorDefs, 5 + type AppBskyActorStatus, 6 + AppBskyEmbedExternal, 7 + AtUri, 8 + ComAtprotoRepoPutRecord, 9 + } from '@atproto/api' 10 + import {retry} from '@atproto/common-web' 11 + import {msg} from '@lingui/macro' 12 + import {useLingui} from '@lingui/react' 13 + import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 14 + import {isAfter, parseISO} from 'date-fns' 15 + 16 + import {uploadBlob} from '#/lib/api' 17 + import {imageToThumb} from '#/lib/api/resolve' 18 + import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' 19 + import {useAppConfig} from '#/state/appConfig' 20 + import { 21 + updateProfileShadow, 22 + useMaybeProfileShadow, 23 + } from '#/state/cache/profile-shadow' 24 + import {useAgent, useSession} from '#/state/session' 25 + import {useTickEveryMinute} from '#/state/shell' 26 + import * as Toast from '#/view/com/util/Toast' 27 + import {useDialogContext} from '#/components/Dialog' 28 + import {useAnalytics} from '#/analytics' 29 + import {getLiveNowHost, getLiveServiceNames} from '#/features/liveNow/utils' 30 + import type * as bsky from '#/types/bsky' 31 + 32 + export * from '#/features/liveNow/utils' 33 + 34 + export const DEFAULT_ALLOWED_DOMAINS = [ 35 + 'twitch.tv', 36 + 'stream.place', 37 + 'bluecast.app', 38 + ] 39 + 40 + export type LiveNowConfig = { 41 + canGoLive: boolean 42 + currentAccountAllowedHosts: Set<string> 43 + defaultAllowedHosts: Set<string> 44 + allowedHostsExceptionsByDid: Map<string, Set<string>> 45 + } 46 + 47 + export function useLiveNowConfig(): LiveNowConfig { 48 + const ax = useAnalytics() 49 + const {liveNow} = useAppConfig() 50 + const {currentAccount} = useSession() 51 + 52 + return useMemo(() => { 53 + const disabled = ax.features.enabled(ax.features.LiveNowBetaDisable) 54 + 55 + const defaultAllowedHosts = new Set( 56 + DEFAULT_ALLOWED_DOMAINS.concat(liveNow.allow), 57 + ) 58 + const allowedHostsExceptionsByDid = new Map<string, Set<string>>() 59 + for (const ex of liveNow.exceptions) { 60 + allowedHostsExceptionsByDid.set( 61 + ex.did, 62 + new Set(DEFAULT_ALLOWED_DOMAINS.concat(ex.allow)), 63 + ) 64 + } 65 + 66 + if (!currentAccount?.did || disabled) { 67 + return { 68 + canGoLive: false, 69 + currentAccountAllowedHosts: new Set(), 70 + defaultAllowedHosts, 71 + allowedHostsExceptionsByDid, 72 + } 73 + } 74 + 75 + return { 76 + canGoLive: true, 77 + currentAccountAllowedHosts: 78 + allowedHostsExceptionsByDid.get(currentAccount.did) ?? 79 + defaultAllowedHosts, 80 + defaultAllowedHosts, 81 + allowedHostsExceptionsByDid, 82 + } 83 + }, [ax, liveNow, currentAccount]) 84 + } 85 + 86 + export function useActorStatus(actor?: bsky.profile.AnyProfileView) { 87 + const shadowed = useMaybeProfileShadow(actor) 88 + const tick = useTickEveryMinute() 89 + const config = useLiveNowConfig() 90 + 91 + return useMemo(() => { 92 + void tick // revalidate every minute 93 + 94 + if (shadowed && 'status' in shadowed && shadowed.status) { 95 + const isValid = isStatusValidForViewers(shadowed.status, config) 96 + const isDisabled = shadowed.status.isDisabled || false 97 + const isActive = isStatusStillActive(shadowed.status.expiresAt) 98 + if (isValid && !isDisabled && isActive) { 99 + return { 100 + uri: shadowed.status.uri, 101 + cid: shadowed.status.cid, 102 + isDisabled: false, 103 + isActive: true, 104 + status: 'app.bsky.actor.status#live', 105 + embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this 106 + expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this 107 + record: shadowed.status.record, 108 + } satisfies AppBskyActorDefs.StatusView 109 + } 110 + return { 111 + uri: shadowed.status.uri, 112 + cid: shadowed.status.cid, 113 + isDisabled, 114 + isActive: false, 115 + status: 'app.bsky.actor.status#live', 116 + embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this 117 + expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this 118 + record: shadowed.status.record, 119 + } satisfies AppBskyActorDefs.StatusView 120 + } else { 121 + return { 122 + status: '', 123 + isDisabled: false, 124 + isActive: false, 125 + record: {}, 126 + } satisfies AppBskyActorDefs.StatusView 127 + } 128 + }, [shadowed, config, tick]) 129 + } 130 + 131 + export function isStatusStillActive(timeStr: string | undefined) { 132 + if (!timeStr) return false 133 + const now = new Date() 134 + const expiry = parseISO(timeStr) 135 + 136 + return isAfter(expiry, now) 137 + } 138 + 139 + /** 140 + * Validates whether the live status is valid for display in the app. Does NOT 141 + * validate if the status is valid for the acting user e.g. as they go live. 142 + */ 143 + export function isStatusValidForViewers( 144 + status: AppBskyActorDefs.StatusView, 145 + config: LiveNowConfig, 146 + ) { 147 + if (status.status !== 'app.bsky.actor.status#live') return false 148 + if (!status.uri) return false // should not happen, just backwards compat 149 + try { 150 + const {host: liveDid} = new AtUri(status.uri) 151 + if (AppBskyEmbedExternal.isView(status.embed)) { 152 + const host = getLiveNowHost(status.embed.external.uri) 153 + const exception = config.allowedHostsExceptionsByDid.get(liveDid) 154 + const isValidException = exception ? exception.has(host) : false 155 + const isValidForAnyone = config.defaultAllowedHosts.has(host) 156 + return isValidException || isValidForAnyone 157 + } else { 158 + return false 159 + } 160 + } catch { 161 + return false 162 + } 163 + } 164 + 165 + export function useLiveLinkMetaQuery(url: string | null) { 166 + const liveNowConfig = useLiveNowConfig() 167 + const {_} = useLingui() 168 + 169 + const agent = useAgent() 170 + return useQuery({ 171 + enabled: !!url, 172 + queryKey: ['link-meta', url], 173 + queryFn: async () => { 174 + if (!url) return undefined 175 + const host = getLiveNowHost(url) 176 + if (!liveNowConfig.currentAccountAllowedHosts.has(host)) { 177 + const {formatted} = getLiveServiceNames( 178 + liveNowConfig.currentAccountAllowedHosts, 179 + ) 180 + throw new Error( 181 + _( 182 + msg`This service is not supported while the Live feature is in beta. Allowed services: ${formatted}.`, 183 + ), 184 + ) 185 + } 186 + 187 + return await getLinkMeta(agent, url) 188 + }, 189 + }) 190 + } 191 + 192 + export function useUpsertLiveStatusMutation( 193 + duration: number, 194 + linkMeta: LinkMeta | null | undefined, 195 + createdAt?: string, 196 + ) { 197 + const ax = useAnalytics() 198 + const {currentAccount} = useSession() 199 + const agent = useAgent() 200 + const queryClient = useQueryClient() 201 + const control = useDialogContext() 202 + const {_} = useLingui() 203 + 204 + return useMutation({ 205 + mutationFn: async () => { 206 + if (!currentAccount) throw new Error('Not logged in') 207 + 208 + let embed: $Typed<AppBskyEmbedExternal.Main> | undefined 209 + 210 + if (linkMeta) { 211 + let thumb 212 + 213 + if (linkMeta.image) { 214 + try { 215 + const img = await imageToThumb(linkMeta.image) 216 + if (img) { 217 + const blob = await uploadBlob( 218 + agent, 219 + img.source.path, 220 + img.source.mime, 221 + ) 222 + thumb = blob.data.blob 223 + } 224 + } catch (e: any) { 225 + ax.logger.error(`Failed to upload thumbnail for live status`, { 226 + url: linkMeta.url, 227 + image: linkMeta.image, 228 + safeMessage: e, 229 + }) 230 + } 231 + } 232 + 233 + embed = { 234 + $type: 'app.bsky.embed.external', 235 + external: { 236 + $type: 'app.bsky.embed.external#external', 237 + title: linkMeta.title ?? '', 238 + description: linkMeta.description ?? '', 239 + uri: linkMeta.url, 240 + thumb, 241 + }, 242 + } 243 + } 244 + 245 + const record = { 246 + $type: 'app.bsky.actor.status', 247 + createdAt: createdAt ?? new Date().toISOString(), 248 + status: 'app.bsky.actor.status#live', 249 + durationMinutes: duration, 250 + embed, 251 + } satisfies AppBskyActorStatus.Record 252 + 253 + const upsert = async () => { 254 + const repo = currentAccount.did 255 + const collection = 'app.bsky.actor.status' 256 + 257 + const existing = await agent.com.atproto.repo 258 + .getRecord({repo, collection, rkey: 'self'}) 259 + .catch(_e => undefined) 260 + 261 + await agent.com.atproto.repo.putRecord({ 262 + repo, 263 + collection, 264 + rkey: 'self', 265 + record, 266 + swapRecord: existing?.data.cid || null, 267 + }) 268 + } 269 + 270 + await retry(upsert, { 271 + maxRetries: 5, 272 + retryable: e => e instanceof ComAtprotoRepoPutRecord.InvalidSwapError, 273 + }) 274 + 275 + return { 276 + record, 277 + image: linkMeta?.image, 278 + } 279 + }, 280 + onError: (e: any) => { 281 + ax.logger.error(`Failed to upsert live status`, { 282 + url: linkMeta?.url, 283 + image: linkMeta?.image, 284 + safeMessage: e, 285 + }) 286 + }, 287 + onSuccess: ({record, image}) => { 288 + if (createdAt) { 289 + ax.metric('live:edit', {duration: record.durationMinutes}) 290 + } else { 291 + ax.metric('live:create', {duration: record.durationMinutes}) 292 + } 293 + 294 + Toast.show(_(msg`You are now live!`)) 295 + control.close(() => { 296 + if (!currentAccount) return 297 + 298 + const expiresAt = new Date(record.createdAt) 299 + expiresAt.setMinutes(expiresAt.getMinutes() + record.durationMinutes) 300 + 301 + updateProfileShadow(queryClient, currentAccount.did, { 302 + status: { 303 + $type: 'app.bsky.actor.defs#statusView', 304 + status: 'app.bsky.actor.status#live', 305 + isActive: true, 306 + expiresAt: expiresAt.toISOString(), 307 + embed: 308 + record.embed && image 309 + ? { 310 + $type: 'app.bsky.embed.external#view', 311 + external: { 312 + ...record.embed.external, 313 + $type: 'app.bsky.embed.external#viewExternal', 314 + thumb: image, 315 + }, 316 + } 317 + : undefined, 318 + record, 319 + }, 320 + }) 321 + }) 322 + }, 323 + }) 324 + } 325 + 326 + export function useRemoveLiveStatusMutation() { 327 + const ax = useAnalytics() 328 + const {currentAccount} = useSession() 329 + const agent = useAgent() 330 + const queryClient = useQueryClient() 331 + const control = useDialogContext() 332 + const {_} = useLingui() 333 + 334 + return useMutation({ 335 + mutationFn: async () => { 336 + if (!currentAccount) throw new Error('Not logged in') 337 + 338 + await agent.app.bsky.actor.status.delete({ 339 + repo: currentAccount.did, 340 + rkey: 'self', 341 + }) 342 + }, 343 + onError: (e: any) => { 344 + ax.logger.error(`Failed to remove live status`, { 345 + safeMessage: e, 346 + }) 347 + }, 348 + onSuccess: () => { 349 + ax.metric('live:remove', {}) 350 + Toast.show(_(msg`You are no longer live`)) 351 + control.close(() => { 352 + if (!currentAccount) return 353 + 354 + updateProfileShadow(queryClient, currentAccount.did, { 355 + status: undefined, 356 + }) 357 + }) 358 + }, 359 + }) 360 + }
-92
src/lib/actor-status.ts
··· 1 - import {useMemo} from 'react' 2 - import { 3 - type $Typed, 4 - type AppBskyActorDefs, 5 - AppBskyEmbedExternal, 6 - AtUri, 7 - } from '@atproto/api' 8 - import {isAfter, parseISO} from 'date-fns' 9 - 10 - import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' 11 - import {type LiveNowConfig, useLiveNowConfig} from '#/state/service-config' 12 - import {useTickEveryMinute} from '#/state/shell' 13 - import type * as bsky from '#/types/bsky' 14 - 15 - export function useActorStatus(actor?: bsky.profile.AnyProfileView) { 16 - const shadowed = useMaybeProfileShadow(actor) 17 - const tick = useTickEveryMinute() 18 - const config = useLiveNowConfig() 19 - 20 - return useMemo(() => { 21 - void tick // revalidate every minute 22 - 23 - if (shadowed && 'status' in shadowed && shadowed.status) { 24 - const isValid = isStatusValidForViewers(shadowed.status, config) 25 - const isDisabled = shadowed.status.isDisabled || false 26 - const isActive = isStatusStillActive(shadowed.status.expiresAt) 27 - if (isValid && !isDisabled && isActive) { 28 - return { 29 - uri: shadowed.status.uri, 30 - cid: shadowed.status.cid, 31 - isDisabled: false, 32 - isActive: true, 33 - status: 'app.bsky.actor.status#live', 34 - embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this 35 - expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this 36 - record: shadowed.status.record, 37 - } satisfies AppBskyActorDefs.StatusView 38 - } 39 - return { 40 - uri: shadowed.status.uri, 41 - cid: shadowed.status.cid, 42 - isDisabled, 43 - isActive: false, 44 - status: 'app.bsky.actor.status#live', 45 - embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this 46 - expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this 47 - record: shadowed.status.record, 48 - } satisfies AppBskyActorDefs.StatusView 49 - } else { 50 - return { 51 - status: '', 52 - isDisabled: false, 53 - isActive: false, 54 - record: {}, 55 - } satisfies AppBskyActorDefs.StatusView 56 - } 57 - }, [shadowed, config, tick]) 58 - } 59 - 60 - export function isStatusStillActive(timeStr: string | undefined) { 61 - if (!timeStr) return false 62 - const now = new Date() 63 - const expiry = parseISO(timeStr) 64 - 65 - return isAfter(expiry, now) 66 - } 67 - 68 - /** 69 - * Validates whether the live status is valid for display in the app. Does NOT 70 - * validate if the status is valid for the acting user e.g. as they go live. 71 - */ 72 - export function isStatusValidForViewers( 73 - status: AppBskyActorDefs.StatusView, 74 - config: LiveNowConfig, 75 - ) { 76 - if (status.status !== 'app.bsky.actor.status#live') return false 77 - if (!status.uri) return false // should not happen, just backwards compat 78 - try { 79 - const {host: liveDid} = new AtUri(status.uri) 80 - if (AppBskyEmbedExternal.isView(status.embed)) { 81 - const url = new URL(status.embed.external.uri) 82 - const exception = config.allowedHostsExceptionsByDid.get(liveDid) 83 - const isValidException = exception ? exception.has(url.hostname) : false 84 - const isValidForAnyone = config.defaultAllowedHosts.has(url.hostname) 85 - return isValidException || isValidForAnyone 86 - } else { 87 - return false 88 - } 89 - } catch { 90 - return false 91 - } 92 - }
···
+2 -1
src/lib/async/retry.ts
··· 29 export async function networkRetry<P>( 30 retries: number, 31 fn: () => Promise<P>, 32 ): Promise<P> { 33 - return retry(retries, isNetworkError, fn) 34 }
··· 29 export async function networkRetry<P>( 30 retries: number, 31 fn: () => Promise<P>, 32 + delay?: number, 33 ): Promise<P> { 34 + return retry(retries, isNetworkError, fn, delay) 35 }
+16
src/lib/hooks/useDebouncedValue.ts
···
··· 1 + import {useEffect, useState} from 'react' 2 + 3 + /** 4 + * Returns a debounced version of the input value that only updates after the 5 + * specified delay has passed without any changes to the input value. 6 + */ 7 + export function useDebouncedValue<T>(val: T, delayMs: number): T { 8 + const [prev, setPrev] = useState(val) 9 + 10 + useEffect(() => { 11 + const timeout = setTimeout(() => setPrev(val), delayMs) 12 + return () => clearTimeout(timeout) 13 + }, [val, delayMs]) 14 + 15 + return prev 16 + }
+1 -1
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 10 import {msg, Plural, Trans} from '@lingui/macro' 11 import {useLingui} from '@lingui/react' 12 13 - import {useActorStatus} from '#/lib/actor-status' 14 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 15 import {useTranslate} from '#/lib/hooks/useTranslate' 16 import {makeProfileLink} from '#/lib/routes/links' ··· 60 import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 61 import {WhoCanReply} from '#/components/WhoCanReply' 62 import {useAnalytics} from '#/analytics' 63 import * as bsky from '#/types/bsky' 64 65 export function ThreadItemAnchor({
··· 10 import {msg, Plural, Trans} from '@lingui/macro' 11 import {useLingui} from '@lingui/react' 12 13 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 14 import {useTranslate} from '#/lib/hooks/useTranslate' 15 import {makeProfileLink} from '#/lib/routes/links' ··· 59 import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 60 import {WhoCanReply} from '#/components/WhoCanReply' 61 import {useAnalytics} from '#/analytics' 62 + import {useActorStatus} from '#/features/liveNow' 63 import * as bsky from '#/types/bsky' 64 65 export function ThreadItemAnchor({
+1 -1
src/screens/PostThread/components/ThreadItemPost.tsx
··· 8 } from '@atproto/api' 9 import {Trans} from '@lingui/macro' 10 11 - import {useActorStatus} from '#/lib/actor-status' 12 import {MAX_POST_LINES} from '#/lib/constants' 13 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 14 import {makeProfileLink} from '#/lib/routes/links' ··· 44 import * as Skele from '#/components/Skeleton' 45 import {SubtleHover} from '#/components/SubtleHover' 46 import {Text} from '#/components/Typography' 47 48 export type ThreadItemPostProps = { 49 item: Extract<ThreadItem, {type: 'threadPost'}>
··· 8 } from '@atproto/api' 9 import {Trans} from '@lingui/macro' 10 11 import {MAX_POST_LINES} from '#/lib/constants' 12 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 13 import {makeProfileLink} from '#/lib/routes/links' ··· 43 import * as Skele from '#/components/Skeleton' 44 import {SubtleHover} from '#/components/SubtleHover' 45 import {Text} from '#/components/Typography' 46 + import {useActorStatus} from '#/features/liveNow' 47 48 export type ThreadItemPostProps = { 49 item: Extract<ThreadItem, {type: 'threadPost'}>
+1 -1
src/screens/Profile/Header/ProfileHeaderStandard.tsx
··· 10 import {msg, Trans} from '@lingui/macro' 11 import {useLingui} from '@lingui/react' 12 13 - import {useActorStatus} from '#/lib/actor-status' 14 import {useHaptics} from '#/lib/haptics' 15 import {sanitizeDisplayName} from '#/lib/strings/display-names' 16 import {sanitizeHandle} from '#/lib/strings/handles' ··· 39 import {Text} from '#/components/Typography' 40 import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 41 import {IS_IOS} from '#/env' 42 import {GermButton} from '../components/GermButton' 43 import {EditProfileDialog} from './EditProfileDialog' 44 import {ProfileHeaderHandle} from './Handle'
··· 10 import {msg, Trans} from '@lingui/macro' 11 import {useLingui} from '@lingui/react' 12 13 import {useHaptics} from '#/lib/haptics' 14 import {sanitizeDisplayName} from '#/lib/strings/display-names' 15 import {sanitizeHandle} from '#/lib/strings/handles' ··· 38 import {Text} from '#/components/Typography' 39 import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 40 import {IS_IOS} from '#/env' 41 + import {useActorStatus} from '#/features/liveNow' 42 import {GermButton} from '../components/GermButton' 43 import {EditProfileDialog} from './EditProfileDialog' 44 import {ProfileHeaderHandle} from './Handle'
+4 -4
src/screens/Profile/Header/Shell.tsx
··· 14 import {useLingui} from '@lingui/react' 15 import {useNavigation} from '@react-navigation/native' 16 17 - import {useActorStatus} from '#/lib/actor-status' 18 import {BACK_HITSLOP} from '#/lib/constants' 19 import {useHaptics} from '#/lib/haptics' 20 import {type NavigationProp} from '#/lib/routes/types' ··· 28 import {Button} from '#/components/Button' 29 import {useDialogControl} from '#/components/Dialog' 30 import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' 31 - import {EditLiveDialog} from '#/components/live/EditLiveDialog' 32 - import {LiveIndicator} from '#/components/live/LiveIndicator' 33 - import {LiveStatusDialog} from '#/components/live/LiveStatusDialog' 34 import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' 35 import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' 36 import {useAnalytics} from '#/analytics' 37 import {IS_IOS} from '#/env' 38 import {GrowableAvatar} from './GrowableAvatar' 39 import {GrowableBanner} from './GrowableBanner' 40 import {StatusBarShadow} from './StatusBarShadow'
··· 14 import {useLingui} from '@lingui/react' 15 import {useNavigation} from '@react-navigation/native' 16 17 import {BACK_HITSLOP} from '#/lib/constants' 18 import {useHaptics} from '#/lib/haptics' 19 import {type NavigationProp} from '#/lib/routes/types' ··· 27 import {Button} from '#/components/Button' 28 import {useDialogControl} from '#/components/Dialog' 29 import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' 30 import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' 31 import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' 32 import {useAnalytics} from '#/analytics' 33 import {IS_IOS} from '#/env' 34 + import {useActorStatus} from '#/features/liveNow' 35 + import {EditLiveDialog} from '#/features/liveNow/components/EditLiveDialog' 36 + import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator' 37 + import {LiveStatusDialog} from '#/features/liveNow/components/LiveStatusDialog' 38 import {GrowableAvatar} from './GrowableAvatar' 39 import {GrowableBanner} from './GrowableBanner' 40 import {StatusBarShadow} from './StatusBarShadow'
+1 -1
src/screens/Settings/Settings.tsx
··· 7 import {useNavigation} from '@react-navigation/native' 8 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 9 10 - import {useActorStatus} from '#/lib/actor-status' 11 import {HELP_DESK_URL} from '#/lib/constants' 12 import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' 13 import {useApplyPullRequestOTAUpdate} from '#/lib/hooks/useOTAUpdates' ··· 70 } from '#/components/verification/VerificationCheckButton' 71 import {useAnalytics} from '#/analytics' 72 import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env' 73 import {device, useStorage} from '#/storage' 74 import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged' 75
··· 7 import {useNavigation} from '@react-navigation/native' 8 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 9 10 import {HELP_DESK_URL} from '#/lib/constants' 11 import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' 12 import {useApplyPullRequestOTAUpdate} from '#/lib/hooks/useOTAUpdates' ··· 69 } from '#/components/verification/VerificationCheckButton' 70 import {useAnalytics} from '#/analytics' 71 import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env' 72 + import {useActorStatus} from '#/features/liveNow' 73 import {device, useStorage} from '#/storage' 74 import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged' 75
+87
src/state/appConfig.tsx
···
··· 1 + import {createContext, useContext} from 'react' 2 + import {QueryClient, useQuery} from '@tanstack/react-query' 3 + 4 + import {APP_CONFIG_URL} from '#/env' 5 + 6 + const qc = new QueryClient() 7 + const appConfigQueryKey = ['app-config'] 8 + 9 + /** 10 + * Matches the types defined in our `app-config` worker 11 + */ 12 + type AppConfigResponse = { 13 + liveNow: { 14 + allow: string[] 15 + exceptions: { 16 + did: string 17 + allow: string[] 18 + }[] 19 + } 20 + } 21 + 22 + export const DEFAULT_APP_CONFIG_RESPONSE: AppConfigResponse = { 23 + liveNow: { 24 + allow: [], 25 + exceptions: [], 26 + }, 27 + } 28 + 29 + let fetchAppConfigPromise: Promise<AppConfigResponse> | undefined 30 + 31 + async function fetchAppConfig(): Promise<AppConfigResponse | null> { 32 + try { 33 + if (!fetchAppConfigPromise) { 34 + fetchAppConfigPromise = (async () => { 35 + const r = await fetch(`${APP_CONFIG_URL}/config`) 36 + if (!r.ok) throw new Error(await r.text()) 37 + const data = await r.json() 38 + return data 39 + })() 40 + } 41 + return await fetchAppConfigPromise 42 + } catch (e) { 43 + fetchAppConfigPromise = undefined 44 + throw e 45 + } 46 + } 47 + 48 + const Context = createContext<AppConfigResponse>(DEFAULT_APP_CONFIG_RESPONSE) 49 + 50 + export function Provider({children}: React.PropsWithChildren<{}>) { 51 + const {data} = useQuery<AppConfigResponse | null>( 52 + { 53 + staleTime: Infinity, 54 + queryKey: appConfigQueryKey, 55 + refetchInterval: query => { 56 + // refetch regularly if fetch failed, otherwise never refetch 57 + return query.state.status === 'error' ? 60e3 : Infinity 58 + }, 59 + async queryFn() { 60 + return fetchAppConfig() 61 + }, 62 + }, 63 + qc, 64 + ) 65 + return ( 66 + <Context.Provider value={data ?? DEFAULT_APP_CONFIG_RESPONSE}> 67 + {children} 68 + </Context.Provider> 69 + ) 70 + } 71 + 72 + export async function prefetchAppConfig() { 73 + try { 74 + const data = await fetchAppConfig() 75 + if (data) { 76 + qc.setQueryData(appConfigQueryKey, data) 77 + } 78 + } catch {} 79 + } 80 + 81 + export function useAppConfig() { 82 + const ctx = useContext(Context) 83 + if (!ctx) { 84 + throw new Error('useAppConfig must be used within a Provider') 85 + } 86 + return ctx 87 + }
+1 -1
src/state/queries/handle-availability.ts
··· 6 BSKY_SERVICE_DID, 7 PUBLIC_BSKY_SERVICE, 8 } from '#/lib/constants' 9 import {createFullHandle} from '#/lib/strings/handles' 10 - import {useDebouncedValue} from '#/components/live/utils' 11 import {useAnalytics} from '#/analytics' 12 import * as bsky from '#/types/bsky' 13 import {Agent} from '../session/agent'
··· 6 BSKY_SERVICE_DID, 7 PUBLIC_BSKY_SERVICE, 8 } from '#/lib/constants' 9 + import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue' 10 import {createFullHandle} from '#/lib/strings/handles' 11 import {useAnalytics} from '#/analytics' 12 import * as bsky from '#/types/bsky' 13 import {Agent} from '../session/agent'
+3 -67
src/state/service-config.tsx
··· 2 3 import {useLanguagePrefs} from '#/state/preferences/languages' 4 import {useServiceConfigQuery} from '#/state/queries/service-config' 5 - import {useSession} from '#/state/session' 6 - import {useAnalytics} from '#/analytics' 7 - import {IS_DEV} from '#/env' 8 import {device} from '#/storage' 9 10 type TrendingContext = { 11 enabled: boolean 12 } 13 14 - type LiveNowContext = { 15 - did: string 16 - domains: string[] 17 - }[] 18 - 19 const TrendingContext = createContext<TrendingContext>({ 20 enabled: false, 21 }) 22 TrendingContext.displayName = 'TrendingContext' 23 - 24 - const LiveNowContext = createContext<LiveNowContext>([]) 25 - LiveNowContext.displayName = 'LiveNowContext' 26 27 const CheckEmailConfirmedContext = createContext<boolean | null>(null) 28 ··· 60 return {enabled} 61 }, [isInitialLoad, config, langPrefs.contentLanguages]) 62 63 - const liveNow = useMemo<LiveNowContext>(() => config?.liveNow ?? [], [config]) 64 - 65 // probably true, so default to true when loading 66 // if the call fails, the query will set it to false for us 67 const checkEmailConfirmed = config?.checkEmailConfirmed ?? true 68 69 return ( 70 <TrendingContext.Provider value={trending}> 71 - <LiveNowContext.Provider value={liveNow}> 72 - <CheckEmailConfirmedContext.Provider value={checkEmailConfirmed}> 73 - {children} 74 - </CheckEmailConfirmedContext.Provider> 75 - </LiveNowContext.Provider> 76 </TrendingContext.Provider> 77 ) 78 } 79 80 export function useTrendingConfig() { 81 return useContext(TrendingContext) 82 - } 83 - 84 - const DEFAULT_LIVE_ALLOWED_DOMAINS = [ 85 - 'twitch.tv', 86 - 'www.twitch.tv', 87 - 'stream.place', 88 - 'bluecast.app', 89 - 'www.bluecast.app', 90 - ] 91 - export type LiveNowConfig = { 92 - currentAccountAllowedHosts: Set<string> 93 - defaultAllowedHosts: Set<string> 94 - allowedHostsExceptionsByDid: Map<string, Set<string>> 95 - } 96 - export function useLiveNowConfig(): LiveNowConfig { 97 - const ctx = useContext(LiveNowContext) 98 - const canGoLive = useCanGoLive() 99 - const {currentAccount} = useSession() 100 - return useMemo(() => { 101 - const defaultAllowedHosts = new Set(DEFAULT_LIVE_ALLOWED_DOMAINS) 102 - const allowedHostsExceptionsByDid = new Map<string, Set<string>>() 103 - for (const live of ctx) { 104 - allowedHostsExceptionsByDid.set( 105 - live.did, 106 - new Set(DEFAULT_LIVE_ALLOWED_DOMAINS.concat(live.domains)), 107 - ) 108 - } 109 - if (!currentAccount?.did || !canGoLive) 110 - return { 111 - currentAccountAllowedHosts: new Set(), 112 - defaultAllowedHosts, 113 - allowedHostsExceptionsByDid, 114 - } 115 - const vip = ctx.find(live => live.did === currentAccount.did) 116 - return { 117 - currentAccountAllowedHosts: new Set( 118 - DEFAULT_LIVE_ALLOWED_DOMAINS.concat(vip ? vip.domains : []), 119 - ), 120 - defaultAllowedHosts, 121 - allowedHostsExceptionsByDid, 122 - } 123 - }, [ctx, currentAccount, canGoLive]) 124 - } 125 - 126 - export function useCanGoLive() { 127 - const ax = useAnalytics() 128 - const {hasSession} = useSession() 129 - if (!hasSession) return false 130 - return IS_DEV ? true : !ax.features.enabled(ax.features.LiveNowBetaDisable) 131 } 132 133 export function useCheckEmailConfirmed() {
··· 2 3 import {useLanguagePrefs} from '#/state/preferences/languages' 4 import {useServiceConfigQuery} from '#/state/queries/service-config' 5 import {device} from '#/storage' 6 7 type TrendingContext = { 8 enabled: boolean 9 } 10 11 const TrendingContext = createContext<TrendingContext>({ 12 enabled: false, 13 }) 14 TrendingContext.displayName = 'TrendingContext' 15 16 const CheckEmailConfirmedContext = createContext<boolean | null>(null) 17 ··· 49 return {enabled} 50 }, [isInitialLoad, config, langPrefs.contentLanguages]) 51 52 // probably true, so default to true when loading 53 // if the call fails, the query will set it to false for us 54 const checkEmailConfirmed = config?.checkEmailConfirmed ?? true 55 56 return ( 57 <TrendingContext.Provider value={trending}> 58 + <CheckEmailConfirmedContext.Provider value={checkEmailConfirmed}> 59 + {children} 60 + </CheckEmailConfirmedContext.Provider> 61 </TrendingContext.Provider> 62 ) 63 } 64 65 export function useTrendingConfig() { 66 return useContext(TrendingContext) 67 } 68 69 export function useCheckEmailConfirmed() {
+5 -2
src/view/com/posts/PostFeed.tsx
··· 19 import {useLingui} from '@lingui/react' 20 import {useQueryClient} from '@tanstack/react-query' 21 22 - import {isStatusStillActive, isStatusValidForViewers} from '#/lib/actor-status' 23 import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' 24 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 25 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' ··· 40 RQKEY, 41 usePostFeedQuery, 42 } from '#/state/queries/post-feed' 43 - import {useLiveNowConfig} from '#/state/service-config' 44 import {useSession} from '#/state/session' 45 import {useProgressGuide} from '#/state/shell/progress-guide' 46 import {useSelectedFeed} from '#/state/shell/selected-feed' ··· 63 import {useAnalytics} from '#/analytics' 64 import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env' 65 import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner' 66 import {ComposerPrompt} from '../feeds/ComposerPrompt' 67 import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' 68 import {FeedShutdownMsg} from './FeedShutdownMsg'
··· 19 import {useLingui} from '@lingui/react' 20 import {useQueryClient} from '@tanstack/react-query' 21 22 import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' 23 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 24 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' ··· 39 RQKEY, 40 usePostFeedQuery, 41 } from '#/state/queries/post-feed' 42 import {useSession} from '#/state/session' 43 import {useProgressGuide} from '#/state/shell/progress-guide' 44 import {useSelectedFeed} from '#/state/shell/selected-feed' ··· 61 import {useAnalytics} from '#/analytics' 62 import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env' 63 import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner' 64 + import { 65 + isStatusStillActive, 66 + isStatusValidForViewers, 67 + useLiveNowConfig, 68 + } from '#/features/liveNow' 69 import {ComposerPrompt} from '../feeds/ComposerPrompt' 70 import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' 71 import {FeedShutdownMsg} from './FeedShutdownMsg'
+1 -1
src/view/com/posts/PostFeedItem.tsx
··· 11 } from '@atproto/api' 12 import {useQueryClient} from '@tanstack/react-query' 13 14 - import {useActorStatus} from '#/lib/actor-status' 15 import {type ReasonFeedSource} from '#/lib/api/feed/types' 16 import {MAX_POST_LINES} from '#/lib/constants' 17 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' ··· 48 import {RichText} from '#/components/RichText' 49 import {SubtleHover} from '#/components/SubtleHover' 50 import {useAnalytics} from '#/analytics' 51 import * as bsky from '#/types/bsky' 52 import {PostFeedReason} from './PostFeedReason' 53
··· 11 } from '@atproto/api' 12 import {useQueryClient} from '@tanstack/react-query' 13 14 import {type ReasonFeedSource} from '#/lib/api/feed/types' 15 import {MAX_POST_LINES} from '#/lib/constants' 16 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' ··· 47 import {RichText} from '#/components/RichText' 48 import {SubtleHover} from '#/components/SubtleHover' 49 import {useAnalytics} from '#/analytics' 50 + import {useActorStatus} from '#/features/liveNow' 51 import * as bsky from '#/types/bsky' 52 import {PostFeedReason} from './PostFeedReason' 53
+5 -6
src/view/com/profile/ProfileMenu.tsx
··· 5 import {useNavigation} from '@react-navigation/native' 6 import {useQueryClient} from '@tanstack/react-query' 7 8 - import {useActorStatus} from '#/lib/actor-status' 9 import {HITSLOP_20} from '#/lib/constants' 10 import {makeProfileLink} from '#/lib/routes/links' 11 import {type NavigationProp} from '#/lib/routes/types' ··· 20 useProfileFollowMutationQueue, 21 useProfileMuteMutationQueue, 22 } from '#/state/queries/profile' 23 - import {useCanGoLive} from '#/state/service-config' 24 import {useSession} from '#/state/session' 25 import {EventStopper} from '#/view/com/util/EventStopper' 26 import * as Toast from '#/view/com/util/Toast' ··· 47 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 48 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 49 import {StarterPack} from '#/components/icons/StarterPack' 50 - import {EditLiveDialog} from '#/components/live/EditLiveDialog' 51 - import {GoLiveDialog} from '#/components/live/GoLiveDialog' 52 - import {GoLiveDisabledDialog} from '#/components/live/GoLiveDisabledDialog' 53 import * as Menu from '#/components/Menu' 54 import { 55 ReportDialog, ··· 61 import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' 62 import {useAnalytics} from '#/analytics' 63 import {IS_WEB} from '#/env' 64 import {Dot} from '#/features/nuxs/components/Dot' 65 import {Gradient} from '#/features/nuxs/components/Gradient' 66 import {useDevMode} from '#/storage/hooks/dev-mode' ··· 85 const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked 86 const [devModeEnabled] = useDevMode() 87 const verification = useFullVerificationState({profile}) 88 - const canGoLive = useCanGoLive() 89 const status = useActorStatus(profile) 90 const statusNudge = useNux(Nux.LiveNowBetaNudge) 91 const statusNudgeActive =
··· 5 import {useNavigation} from '@react-navigation/native' 6 import {useQueryClient} from '@tanstack/react-query' 7 8 import {HITSLOP_20} from '#/lib/constants' 9 import {makeProfileLink} from '#/lib/routes/links' 10 import {type NavigationProp} from '#/lib/routes/types' ··· 19 useProfileFollowMutationQueue, 20 useProfileMuteMutationQueue, 21 } from '#/state/queries/profile' 22 import {useSession} from '#/state/session' 23 import {EventStopper} from '#/view/com/util/EventStopper' 24 import * as Toast from '#/view/com/util/Toast' ··· 45 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 46 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 47 import {StarterPack} from '#/components/icons/StarterPack' 48 import * as Menu from '#/components/Menu' 49 import { 50 ReportDialog, ··· 56 import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' 57 import {useAnalytics} from '#/analytics' 58 import {IS_WEB} from '#/env' 59 + import {useActorStatus, useLiveNowConfig} from '#/features/liveNow' 60 + import {EditLiveDialog} from '#/features/liveNow/components/EditLiveDialog' 61 + import {GoLiveDialog} from '#/features/liveNow/components/GoLiveDialog' 62 + import {GoLiveDisabledDialog} from '#/features/liveNow/components/GoLiveDisabledDialog' 63 import {Dot} from '#/features/nuxs/components/Dot' 64 import {Gradient} from '#/features/nuxs/components/Gradient' 65 import {useDevMode} from '#/storage/hooks/dev-mode' ··· 84 const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked 85 const [devModeEnabled] = useDevMode() 86 const verification = useFullVerificationState({profile}) 87 + const {canGoLive} = useLiveNowConfig() 88 const status = useActorStatus(profile) 89 const statusNudge = useNux(Nux.LiveNowBetaNudge) 90 const statusNudgeActive =
+1 -1
src/view/com/util/PostMeta.tsx
··· 5 import {useLingui} from '@lingui/react' 6 import {useQueryClient} from '@tanstack/react-query' 7 8 - import {useActorStatus} from '#/lib/actor-status' 9 import {makeProfileLink} from '#/lib/routes/links' 10 import {forceLTR} from '#/lib/strings/bidi' 11 import {NON_BREAKING_SPACE} from '#/lib/strings/constants' ··· 21 import {useSimpleVerificationState} from '#/components/verification' 22 import {VerificationCheck} from '#/components/verification/VerificationCheck' 23 import {IS_ANDROID} from '#/env' 24 import {TimeElapsed} from './TimeElapsed' 25 import {PreviewableUserAvatar} from './UserAvatar' 26
··· 5 import {useLingui} from '@lingui/react' 6 import {useQueryClient} from '@tanstack/react-query' 7 8 import {makeProfileLink} from '#/lib/routes/links' 9 import {forceLTR} from '#/lib/strings/bidi' 10 import {NON_BREAKING_SPACE} from '#/lib/strings/constants' ··· 20 import {useSimpleVerificationState} from '#/components/verification' 21 import {VerificationCheck} from '#/components/verification/VerificationCheck' 22 import {IS_ANDROID} from '#/env' 23 + import {useActorStatus} from '#/features/liveNow' 24 import {TimeElapsed} from './TimeElapsed' 25 import {PreviewableUserAvatar} from './UserAvatar' 26
+3 -3
src/view/com/util/UserAvatar.tsx
··· 15 import {useLingui} from '@lingui/react' 16 import {useQueryClient} from '@tanstack/react-query' 17 18 - import {useActorStatus} from '#/lib/actor-status' 19 import {useHaptics} from '#/lib/haptics' 20 import { 21 useCameraPermission, ··· 47 import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive' 48 import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 49 import {Link} from '#/components/Link' 50 - import {LiveIndicator} from '#/components/live/LiveIndicator' 51 - import {LiveStatusDialog} from '#/components/live/LiveStatusDialog' 52 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 53 import * as Menu from '#/components/Menu' 54 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 55 import {useAnalytics} from '#/analytics' 56 import {IS_ANDROID, IS_NATIVE, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' 57 import type * as bsky from '#/types/bsky' 58 59 export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
··· 15 import {useLingui} from '@lingui/react' 16 import {useQueryClient} from '@tanstack/react-query' 17 18 import {useHaptics} from '#/lib/haptics' 19 import { 20 useCameraPermission, ··· 46 import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive' 47 import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 48 import {Link} from '#/components/Link' 49 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 50 import * as Menu from '#/components/Menu' 51 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 52 import {useAnalytics} from '#/analytics' 53 import {IS_ANDROID, IS_NATIVE, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' 54 + import {useActorStatus} from '#/features/liveNow' 55 + import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator' 56 + import {LiveStatusDialog} from '#/features/liveNow/components/LiveStatusDialog' 57 import type * as bsky from '#/types/bsky' 58 59 export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
+1 -1
src/view/shell/Drawer.tsx
··· 5 import {useLingui} from '@lingui/react' 6 import {StackActions, useNavigation} from '@react-navigation/native' 7 8 - import {useActorStatus} from '#/lib/actor-status' 9 import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants' 10 import {type PressableScale} from '#/lib/custom-animations/PressableScale' 11 import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState' ··· 57 import {useSimpleVerificationState} from '#/components/verification' 58 import {VerificationCheck} from '#/components/verification/VerificationCheck' 59 import {IS_WEB} from '#/env' 60 61 const iconWidth = 26 62
··· 5 import {useLingui} from '@lingui/react' 6 import {StackActions, useNavigation} from '@react-navigation/native' 7 8 import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants' 9 import {type PressableScale} from '#/lib/custom-animations/PressableScale' 10 import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState' ··· 56 import {useSimpleVerificationState} from '#/components/verification' 57 import {VerificationCheck} from '#/components/verification/VerificationCheck' 58 import {IS_WEB} from '#/env' 59 + import {useActorStatus} from '#/features/liveNow' 60 61 const iconWidth = 26 62
+1 -1
src/view/shell/bottom-bar/BottomBar.tsx
··· 7 import {type BottomTabBarProps} from '@react-navigation/bottom-tabs' 8 import {StackActions} from '@react-navigation/native' 9 10 - import {useActorStatus} from '#/lib/actor-status' 11 import {PressableScale} from '#/lib/custom-animations/PressableScale' 12 import {BOTTOM_BAR_AVI} from '#/lib/demo' 13 import {useHaptics} from '#/lib/haptics' ··· 49 Message_Stroke2_Corner0_Rounded_Filled as MessageFilled, 50 } from '#/components/icons/Message' 51 import {Text} from '#/components/Typography' 52 import {useDemoMode} from '#/storage/hooks/demo-mode' 53 import {styles} from './BottomBarStyles' 54
··· 7 import {type BottomTabBarProps} from '@react-navigation/bottom-tabs' 8 import {StackActions} from '@react-navigation/native' 9 10 import {PressableScale} from '#/lib/custom-animations/PressableScale' 11 import {BOTTOM_BAR_AVI} from '#/lib/demo' 12 import {useHaptics} from '#/lib/haptics' ··· 48 Message_Stroke2_Corner0_Rounded_Filled as MessageFilled, 49 } from '#/components/icons/Message' 50 import {Text} from '#/components/Typography' 51 + import {useActorStatus} from '#/features/liveNow' 52 import {useDemoMode} from '#/storage/hooks/demo-mode' 53 import {styles} from './BottomBarStyles' 54
+1 -1
src/view/shell/desktop/LeftNav.tsx
··· 5 import {useLingui} from '@lingui/react' 6 import {useNavigation, useNavigationState} from '@react-navigation/native' 7 8 - import {useActorStatus} from '#/lib/actor-status' 9 import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' 10 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 11 import {usePalette} from '#/lib/hooks/usePalette' ··· 74 import * as Menu from '#/components/Menu' 75 import * as Prompt from '#/components/Prompt' 76 import {Text} from '#/components/Typography' 77 import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army' 78 import {router} from '../../../routes' 79
··· 5 import {useLingui} from '@lingui/react' 6 import {useNavigation, useNavigationState} from '@react-navigation/native' 7 8 import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' 9 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 10 import {usePalette} from '#/lib/hooks/usePalette' ··· 73 import * as Menu from '#/components/Menu' 74 import * as Prompt from '#/components/Prompt' 75 import {Text} from '#/components/Typography' 76 + import {useActorStatus} from '#/features/liveNow' 77 import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army' 78 import {router} from '../../../routes' 79