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 46 47 47 # live-events web worker URL 48 48 LIVE_EVENTS_DEV_URL= 49 + 50 + # app-config web worker URL 51 + APP_CONFIG_DEV_URL=
+39 -32
src/App.native.tsx
··· 22 22 import I18nProvider from '#/locale/i18nProvider' 23 23 import {logger} from '#/logger' 24 24 import {Provider as A11yProvider} from '#/state/a11y' 25 + import { 26 + prefetchAppConfig, 27 + Provider as AppConfigProvider, 28 + } from '#/state/appConfig' 25 29 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 26 30 import {Provider as DialogStateProvider} from '#/state/dialogs' 27 31 import {Provider as EmailVerificationProvider} from '#/state/email-verification' ··· 103 107 Geo.resolve() 104 108 prefetchAgeAssuranceConfig() 105 109 prefetchLiveEvents() 110 + prefetchAppConfig() 106 111 107 112 function InnerApp() { 108 113 const [isReady, setIsReady] = React.useState(false) ··· 228 233 */ 229 234 return ( 230 235 <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> 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> 263 270 </Geo.Provider> 264 271 ) 265 272 }
+32 -25
src/App.web.tsx
··· 13 13 import I18nProvider from '#/locale/i18nProvider' 14 14 import {logger} from '#/logger' 15 15 import {Provider as A11yProvider} from '#/state/a11y' 16 + import { 17 + prefetchAppConfig, 18 + Provider as AppConfigProvider, 19 + } from '#/state/appConfig' 16 20 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 17 21 import {Provider as DialogStateProvider} from '#/state/dialogs' 18 22 import {Provider as EmailVerificationProvider} from '#/state/email-verification' ··· 79 83 Geo.resolve() 80 84 prefetchAgeAssuranceConfig() 81 85 prefetchLiveEvents() 86 + prefetchAppConfig() 82 87 83 88 function InnerApp() { 84 89 const [isReady, setIsReady] = useState(false) ··· 202 207 */ 203 208 return ( 204 209 <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> 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> 230 237 </Geo.Provider> 231 238 ) 232 239 }
+1 -1
src/components/AccountList.tsx
··· 4 4 import {msg, Trans} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 7 - import {useActorStatus} from '#/lib/actor-status' 8 7 import {isJwtExpired} from '#/lib/jwt' 9 8 import {sanitizeDisplayName} from '#/lib/strings/display-names' 10 9 import {sanitizeHandle} from '#/lib/strings/handles' ··· 19 18 import {Text} from '#/components/Typography' 20 19 import {useSimpleVerificationState} from '#/components/verification' 21 20 import {VerificationCheck} from '#/components/verification/VerificationCheck' 21 + import {useActorStatus} from '#/features/liveNow' 22 22 23 23 export function AccountList({ 24 24 onSelectAccount,
+1 -1
src/components/ProfileCard.tsx
··· 14 14 import {msg} from '@lingui/macro' 15 15 import {useLingui} from '@lingui/react' 16 16 17 - import {useActorStatus} from '#/lib/actor-status' 18 17 import {getModerationCauseKey} from '#/lib/moderation' 19 18 import {forceLTR} from '#/lib/strings/bidi' 20 19 import {NON_BREAKING_SPACE} from '#/lib/strings/constants' ··· 47 46 import {useSimpleVerificationState} from '#/components/verification' 48 47 import {VerificationCheck} from '#/components/verification/VerificationCheck' 49 48 import {type Metrics} from '#/analytics' 49 + import {useActorStatus} from '#/features/liveNow' 50 50 import type * as bsky from '#/types/bsky' 51 51 52 52 export function Default({
+2 -2
src/components/ProfileHoverCard/index.web.tsx
··· 10 10 import {useLingui} from '@lingui/react' 11 11 import {useNavigation} from '@react-navigation/native' 12 12 13 - import {useActorStatus} from '#/lib/actor-status' 14 13 import {getModerationCauseKey} from '#/lib/moderation' 15 14 import {makeProfileLink} from '#/lib/routes/links' 16 15 import {type NavigationProp} from '#/lib/routes/types' ··· 34 33 shouldShowKnownFollowers, 35 34 } from '#/components/KnownFollowers' 36 35 import {InlineLinkText, Link} from '#/components/Link' 37 - import {LiveStatus} from '#/components/live/LiveStatusDialog' 38 36 import {Loader} from '#/components/Loader' 39 37 import * as Pills from '#/components/Pills' 40 38 import {Portal} from '#/components/Portal' ··· 43 41 import {useSimpleVerificationState} from '#/components/verification' 44 42 import {VerificationCheck} from '#/components/verification/VerificationCheck' 45 43 import {IS_WEB_TOUCH_DEVICE} from '#/env' 44 + import {useActorStatus} from '#/features/liveNow' 45 + import {LiveStatus} from '#/features/liveNow/components/LiveStatusDialog' 46 46 import {type ProfileHoverCardProps} from './types' 47 47 48 48 const floatingMiddlewares = [
+4 -3
src/components/live/EditLiveDialog.tsx src/features/liveNow/components/EditLiveDialog.tsx
··· 9 9 import {useLingui} from '@lingui/react' 10 10 import {differenceInMinutes} from 'date-fns' 11 11 12 + import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue' 12 13 import {cleanError} from '#/lib/strings/errors' 13 14 import {definitelyUrl} from '#/lib/strings/url-helpers' 14 15 import {useTickEveryMinute} from '#/state/shell' ··· 20 21 import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock' 21 22 import {Loader} from '#/components/Loader' 22 23 import {Text} from '#/components/Typography' 23 - import {LinkPreview} from './LinkPreview' 24 24 import { 25 + displayDuration, 25 26 useLiveLinkMetaQuery, 26 27 useRemoveLiveStatusMutation, 27 28 useUpsertLiveStatusMutation, 28 - } from './queries' 29 - import {displayDuration, useDebouncedValue} from './utils' 29 + } from '#/features/liveNow' 30 + import {LinkPreview} from '#/features/liveNow/components/LinkPreview' 30 31 31 32 export function EditLiveDialog({ 32 33 control,
+8 -7
src/components/live/GoLiveDialog.tsx src/features/liveNow/components/GoLiveDialog.tsx
··· 3 3 import {msg, Trans} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 6 + import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue' 6 7 import {cleanError} from '#/lib/strings/errors' 7 8 import {definitelyUrl} from '#/lib/strings/url-helpers' 8 9 import {useModerationOpts} from '#/state/preferences/moderation-opts' 9 - import {useLiveNowConfig} from '#/state/service-config' 10 10 import {useTickEveryMinute} from '#/state/shell' 11 11 import {atoms as a, ios, native, platform, useTheme, web} from '#/alf' 12 12 import {Admonition} from '#/components/Admonition' 13 13 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 14 14 import * as Dialog from '#/components/Dialog' 15 15 import * as TextField from '#/components/forms/TextField' 16 - import { 17 - displayDuration, 18 - getLiveServiceNames, 19 - useDebouncedValue, 20 - } from '#/components/live/utils' 21 16 import {Loader} from '#/components/Loader' 22 17 import * as ProfileCard from '#/components/ProfileCard' 23 18 import * as Select from '#/components/Select' 24 19 import {Text} from '#/components/Typography' 20 + import { 21 + displayDuration, 22 + getLiveServiceNames, 23 + useLiveLinkMetaQuery, 24 + useLiveNowConfig, 25 + useUpsertLiveStatusMutation, 26 + } from '#/features/liveNow' 25 27 import type * as bsky from '#/types/bsky' 26 28 import {LinkPreview} from './LinkPreview' 27 - import {useLiveLinkMetaQuery, useUpsertLiveStatusMutation} from './queries' 28 29 29 30 export function GoLiveDialog({ 30 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 17 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 18 18 import * as Dialog from '#/components/Dialog' 19 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' 20 22 import {createStaticClick, SimpleInlineLinkText} from '#/components/Link' 21 23 import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog' 22 24 import * as ProfileCard from '#/components/ProfileCard' 23 25 import {Text} from '#/components/Typography' 24 26 import {useAnalytics} from '#/analytics' 27 + import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator' 25 28 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 29 30 30 export function LiveStatusDialog({ 31 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 1 import {type I18n} from '@lingui/core' 3 2 import {plural} from '@lingui/macro' 3 + import psl from 'psl' 4 4 5 5 export function displayDuration(i18n: I18n, durationInMinutes: number) { 6 6 const roundedDurationInMinutes = Math.round(durationInMinutes) ··· 24 24 : minutesString 25 25 } 26 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 27 const serviceUrlToNameMap: Record<string, string> = { 40 28 'twitch.tv': 'Twitch', 41 - 'www.twitch.tv': 'Twitch', 42 29 'youtube.com': 'YouTube', 43 - 'www.youtube.com': 'YouTube', 44 - 'youtu.be': 'YouTube', 45 30 'nba.com': 'NBA', 46 - 'www.nba.com': 'NBA', 47 31 'nba.smart.link': 'nba.smart.link', 48 32 'espn.com': 'ESPN', 49 - 'www.espn.com': 'ESPN', 50 33 'stream.place': 'Streamplace', 51 34 'skylight.social': 'Skylight', 52 35 'bluecast.app': 'Bluecast', 53 - 'www.bluecast.app': 'Bluecast', 54 36 } 55 37 56 38 export function getLiveServiceNames(domains: Set<string>) { 57 39 const names = Array.from( 58 - new Set(Array.from(domains.values()).map(d => serviceUrlToNameMap[d] || d)), 40 + new Set( 41 + Array.from(domains.values()) 42 + .map(d => sanitizeLiveNowHost(d)) 43 + .map(d => serviceUrlToNameMap[d] || d), 44 + ), 59 45 ) 60 46 return { 61 47 names, 62 48 formatted: names.join(', '), 63 49 } 64 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 141 export const LIVE_EVENTS_URL = IS_DEV 142 142 ? (LIVE_EVENTS_DEV_URL ?? LIVE_EVENTS_PROD_URL) 143 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 29 export async function networkRetry<P>( 30 30 retries: number, 31 31 fn: () => Promise<P>, 32 + delay?: number, 32 33 ): Promise<P> { 33 - return retry(retries, isNetworkError, fn) 34 + return retry(retries, isNetworkError, fn, delay) 34 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 10 import {msg, Plural, Trans} from '@lingui/macro' 11 11 import {useLingui} from '@lingui/react' 12 12 13 - import {useActorStatus} from '#/lib/actor-status' 14 13 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 15 14 import {useTranslate} from '#/lib/hooks/useTranslate' 16 15 import {makeProfileLink} from '#/lib/routes/links' ··· 60 59 import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 61 60 import {WhoCanReply} from '#/components/WhoCanReply' 62 61 import {useAnalytics} from '#/analytics' 62 + import {useActorStatus} from '#/features/liveNow' 63 63 import * as bsky from '#/types/bsky' 64 64 65 65 export function ThreadItemAnchor({
+1 -1
src/screens/PostThread/components/ThreadItemPost.tsx
··· 8 8 } from '@atproto/api' 9 9 import {Trans} from '@lingui/macro' 10 10 11 - import {useActorStatus} from '#/lib/actor-status' 12 11 import {MAX_POST_LINES} from '#/lib/constants' 13 12 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 14 13 import {makeProfileLink} from '#/lib/routes/links' ··· 44 43 import * as Skele from '#/components/Skeleton' 45 44 import {SubtleHover} from '#/components/SubtleHover' 46 45 import {Text} from '#/components/Typography' 46 + import {useActorStatus} from '#/features/liveNow' 47 47 48 48 export type ThreadItemPostProps = { 49 49 item: Extract<ThreadItem, {type: 'threadPost'}>
+1 -1
src/screens/Profile/Header/ProfileHeaderStandard.tsx
··· 10 10 import {msg, Trans} from '@lingui/macro' 11 11 import {useLingui} from '@lingui/react' 12 12 13 - import {useActorStatus} from '#/lib/actor-status' 14 13 import {useHaptics} from '#/lib/haptics' 15 14 import {sanitizeDisplayName} from '#/lib/strings/display-names' 16 15 import {sanitizeHandle} from '#/lib/strings/handles' ··· 39 38 import {Text} from '#/components/Typography' 40 39 import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 41 40 import {IS_IOS} from '#/env' 41 + import {useActorStatus} from '#/features/liveNow' 42 42 import {GermButton} from '../components/GermButton' 43 43 import {EditProfileDialog} from './EditProfileDialog' 44 44 import {ProfileHeaderHandle} from './Handle'
+4 -4
src/screens/Profile/Header/Shell.tsx
··· 14 14 import {useLingui} from '@lingui/react' 15 15 import {useNavigation} from '@react-navigation/native' 16 16 17 - import {useActorStatus} from '#/lib/actor-status' 18 17 import {BACK_HITSLOP} from '#/lib/constants' 19 18 import {useHaptics} from '#/lib/haptics' 20 19 import {type NavigationProp} from '#/lib/routes/types' ··· 28 27 import {Button} from '#/components/Button' 29 28 import {useDialogControl} from '#/components/Dialog' 30 29 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 30 import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' 35 31 import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' 36 32 import {useAnalytics} from '#/analytics' 37 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 38 import {GrowableAvatar} from './GrowableAvatar' 39 39 import {GrowableBanner} from './GrowableBanner' 40 40 import {StatusBarShadow} from './StatusBarShadow'
+1 -1
src/screens/Settings/Settings.tsx
··· 7 7 import {useNavigation} from '@react-navigation/native' 8 8 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 9 9 10 - import {useActorStatus} from '#/lib/actor-status' 11 10 import {HELP_DESK_URL} from '#/lib/constants' 12 11 import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' 13 12 import {useApplyPullRequestOTAUpdate} from '#/lib/hooks/useOTAUpdates' ··· 70 69 } from '#/components/verification/VerificationCheckButton' 71 70 import {useAnalytics} from '#/analytics' 72 71 import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env' 72 + import {useActorStatus} from '#/features/liveNow' 73 73 import {device, useStorage} from '#/storage' 74 74 import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged' 75 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 6 BSKY_SERVICE_DID, 7 7 PUBLIC_BSKY_SERVICE, 8 8 } from '#/lib/constants' 9 + import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue' 9 10 import {createFullHandle} from '#/lib/strings/handles' 10 - import {useDebouncedValue} from '#/components/live/utils' 11 11 import {useAnalytics} from '#/analytics' 12 12 import * as bsky from '#/types/bsky' 13 13 import {Agent} from '../session/agent'
+3 -67
src/state/service-config.tsx
··· 2 2 3 3 import {useLanguagePrefs} from '#/state/preferences/languages' 4 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 5 import {device} from '#/storage' 9 6 10 7 type TrendingContext = { 11 8 enabled: boolean 12 9 } 13 10 14 - type LiveNowContext = { 15 - did: string 16 - domains: string[] 17 - }[] 18 - 19 11 const TrendingContext = createContext<TrendingContext>({ 20 12 enabled: false, 21 13 }) 22 14 TrendingContext.displayName = 'TrendingContext' 23 - 24 - const LiveNowContext = createContext<LiveNowContext>([]) 25 - LiveNowContext.displayName = 'LiveNowContext' 26 15 27 16 const CheckEmailConfirmedContext = createContext<boolean | null>(null) 28 17 ··· 60 49 return {enabled} 61 50 }, [isInitialLoad, config, langPrefs.contentLanguages]) 62 51 63 - const liveNow = useMemo<LiveNowContext>(() => config?.liveNow ?? [], [config]) 64 - 65 52 // probably true, so default to true when loading 66 53 // if the call fails, the query will set it to false for us 67 54 const checkEmailConfirmed = config?.checkEmailConfirmed ?? true 68 55 69 56 return ( 70 57 <TrendingContext.Provider value={trending}> 71 - <LiveNowContext.Provider value={liveNow}> 72 - <CheckEmailConfirmedContext.Provider value={checkEmailConfirmed}> 73 - {children} 74 - </CheckEmailConfirmedContext.Provider> 75 - </LiveNowContext.Provider> 58 + <CheckEmailConfirmedContext.Provider value={checkEmailConfirmed}> 59 + {children} 60 + </CheckEmailConfirmedContext.Provider> 76 61 </TrendingContext.Provider> 77 62 ) 78 63 } 79 64 80 65 export function useTrendingConfig() { 81 66 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 67 } 132 68 133 69 export function useCheckEmailConfirmed() {
+5 -2
src/view/com/posts/PostFeed.tsx
··· 19 19 import {useLingui} from '@lingui/react' 20 20 import {useQueryClient} from '@tanstack/react-query' 21 21 22 - import {isStatusStillActive, isStatusValidForViewers} from '#/lib/actor-status' 23 22 import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' 24 23 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 25 24 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' ··· 40 39 RQKEY, 41 40 usePostFeedQuery, 42 41 } from '#/state/queries/post-feed' 43 - import {useLiveNowConfig} from '#/state/service-config' 44 42 import {useSession} from '#/state/session' 45 43 import {useProgressGuide} from '#/state/shell/progress-guide' 46 44 import {useSelectedFeed} from '#/state/shell/selected-feed' ··· 63 61 import {useAnalytics} from '#/analytics' 64 62 import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env' 65 63 import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner' 64 + import { 65 + isStatusStillActive, 66 + isStatusValidForViewers, 67 + useLiveNowConfig, 68 + } from '#/features/liveNow' 66 69 import {ComposerPrompt} from '../feeds/ComposerPrompt' 67 70 import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' 68 71 import {FeedShutdownMsg} from './FeedShutdownMsg'
+1 -1
src/view/com/posts/PostFeedItem.tsx
··· 11 11 } from '@atproto/api' 12 12 import {useQueryClient} from '@tanstack/react-query' 13 13 14 - import {useActorStatus} from '#/lib/actor-status' 15 14 import {type ReasonFeedSource} from '#/lib/api/feed/types' 16 15 import {MAX_POST_LINES} from '#/lib/constants' 17 16 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' ··· 48 47 import {RichText} from '#/components/RichText' 49 48 import {SubtleHover} from '#/components/SubtleHover' 50 49 import {useAnalytics} from '#/analytics' 50 + import {useActorStatus} from '#/features/liveNow' 51 51 import * as bsky from '#/types/bsky' 52 52 import {PostFeedReason} from './PostFeedReason' 53 53
+5 -6
src/view/com/profile/ProfileMenu.tsx
··· 5 5 import {useNavigation} from '@react-navigation/native' 6 6 import {useQueryClient} from '@tanstack/react-query' 7 7 8 - import {useActorStatus} from '#/lib/actor-status' 9 8 import {HITSLOP_20} from '#/lib/constants' 10 9 import {makeProfileLink} from '#/lib/routes/links' 11 10 import {type NavigationProp} from '#/lib/routes/types' ··· 20 19 useProfileFollowMutationQueue, 21 20 useProfileMuteMutationQueue, 22 21 } from '#/state/queries/profile' 23 - import {useCanGoLive} from '#/state/service-config' 24 22 import {useSession} from '#/state/session' 25 23 import {EventStopper} from '#/view/com/util/EventStopper' 26 24 import * as Toast from '#/view/com/util/Toast' ··· 47 45 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 48 46 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 49 47 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 48 import * as Menu from '#/components/Menu' 54 49 import { 55 50 ReportDialog, ··· 61 56 import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' 62 57 import {useAnalytics} from '#/analytics' 63 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' 64 63 import {Dot} from '#/features/nuxs/components/Dot' 65 64 import {Gradient} from '#/features/nuxs/components/Gradient' 66 65 import {useDevMode} from '#/storage/hooks/dev-mode' ··· 85 84 const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked 86 85 const [devModeEnabled] = useDevMode() 87 86 const verification = useFullVerificationState({profile}) 88 - const canGoLive = useCanGoLive() 87 + const {canGoLive} = useLiveNowConfig() 89 88 const status = useActorStatus(profile) 90 89 const statusNudge = useNux(Nux.LiveNowBetaNudge) 91 90 const statusNudgeActive =
+1 -1
src/view/com/util/PostMeta.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 import {useQueryClient} from '@tanstack/react-query' 7 7 8 - import {useActorStatus} from '#/lib/actor-status' 9 8 import {makeProfileLink} from '#/lib/routes/links' 10 9 import {forceLTR} from '#/lib/strings/bidi' 11 10 import {NON_BREAKING_SPACE} from '#/lib/strings/constants' ··· 21 20 import {useSimpleVerificationState} from '#/components/verification' 22 21 import {VerificationCheck} from '#/components/verification/VerificationCheck' 23 22 import {IS_ANDROID} from '#/env' 23 + import {useActorStatus} from '#/features/liveNow' 24 24 import {TimeElapsed} from './TimeElapsed' 25 25 import {PreviewableUserAvatar} from './UserAvatar' 26 26
+3 -3
src/view/com/util/UserAvatar.tsx
··· 15 15 import {useLingui} from '@lingui/react' 16 16 import {useQueryClient} from '@tanstack/react-query' 17 17 18 - import {useActorStatus} from '#/lib/actor-status' 19 18 import {useHaptics} from '#/lib/haptics' 20 19 import { 21 20 useCameraPermission, ··· 47 46 import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive' 48 47 import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 49 48 import {Link} from '#/components/Link' 50 - import {LiveIndicator} from '#/components/live/LiveIndicator' 51 - import {LiveStatusDialog} from '#/components/live/LiveStatusDialog' 52 49 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 53 50 import * as Menu from '#/components/Menu' 54 51 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 55 52 import {useAnalytics} from '#/analytics' 56 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 57 import type * as bsky from '#/types/bsky' 58 58 59 59 export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
+1 -1
src/view/shell/Drawer.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 import {StackActions, useNavigation} from '@react-navigation/native' 7 7 8 - import {useActorStatus} from '#/lib/actor-status' 9 8 import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants' 10 9 import {type PressableScale} from '#/lib/custom-animations/PressableScale' 11 10 import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState' ··· 57 56 import {useSimpleVerificationState} from '#/components/verification' 58 57 import {VerificationCheck} from '#/components/verification/VerificationCheck' 59 58 import {IS_WEB} from '#/env' 59 + import {useActorStatus} from '#/features/liveNow' 60 60 61 61 const iconWidth = 26 62 62
+1 -1
src/view/shell/bottom-bar/BottomBar.tsx
··· 7 7 import {type BottomTabBarProps} from '@react-navigation/bottom-tabs' 8 8 import {StackActions} from '@react-navigation/native' 9 9 10 - import {useActorStatus} from '#/lib/actor-status' 11 10 import {PressableScale} from '#/lib/custom-animations/PressableScale' 12 11 import {BOTTOM_BAR_AVI} from '#/lib/demo' 13 12 import {useHaptics} from '#/lib/haptics' ··· 49 48 Message_Stroke2_Corner0_Rounded_Filled as MessageFilled, 50 49 } from '#/components/icons/Message' 51 50 import {Text} from '#/components/Typography' 51 + import {useActorStatus} from '#/features/liveNow' 52 52 import {useDemoMode} from '#/storage/hooks/demo-mode' 53 53 import {styles} from './BottomBarStyles' 54 54
+1 -1
src/view/shell/desktop/LeftNav.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 import {useNavigation, useNavigationState} from '@react-navigation/native' 7 7 8 - import {useActorStatus} from '#/lib/actor-status' 9 8 import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' 10 9 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 11 10 import {usePalette} from '#/lib/hooks/usePalette' ··· 74 73 import * as Menu from '#/components/Menu' 75 74 import * as Prompt from '#/components/Prompt' 76 75 import {Text} from '#/components/Typography' 76 + import {useActorStatus} from '#/features/liveNow' 77 77 import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army' 78 78 import {router} from '../../../routes' 79 79