diff --git a/.env.example b/.env.example --- a/.env.example +++ b/.env.example @@ -46,3 +46,6 @@ # live-events web worker URL LIVE_EVENTS_DEV_URL= + +# app-config web worker URL +APP_CONFIG_DEV_URL= diff --git a/src/App.native.tsx b/src/App.native.tsx --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -22,6 +22,10 @@ import I18nProvider from '#/locale/i18nProvider' import {logger} from '#/logger' import {Provider as A11yProvider} from '#/state/a11y' +import { + prefetchAppConfig, + Provider as AppConfigProvider, +} from '#/state/appConfig' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' import {Provider as EmailVerificationProvider} from '#/state/email-verification' @@ -103,6 +107,7 @@ Geo.resolve() prefetchAgeAssuranceConfig() prefetchLiveEvents() +prefetchAppConfig() function InnerApp() { const [isReady, setIsReady] = React.useState(false) @@ -228,38 +233,40 @@ */ return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/App.web.tsx b/src/App.web.tsx --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -13,6 +13,10 @@ import I18nProvider from '#/locale/i18nProvider' import {logger} from '#/logger' import {Provider as A11yProvider} from '#/state/a11y' +import { + prefetchAppConfig, + Provider as AppConfigProvider, +} from '#/state/appConfig' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' import {Provider as EmailVerificationProvider} from '#/state/email-verification' @@ -79,6 +83,7 @@ Geo.resolve() prefetchAgeAssuranceConfig() prefetchLiveEvents() +prefetchAppConfig() function InnerApp() { const [isReady, setIsReady] = useState(false) @@ -202,31 +207,33 @@ */ return ( - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/components/AccountList.tsx b/src/components/AccountList.tsx --- a/src/components/AccountList.tsx +++ b/src/components/AccountList.tsx @@ -4,7 +4,6 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useActorStatus} from '#/lib/actor-status' import {isJwtExpired} from '#/lib/jwt' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' @@ -19,6 +18,7 @@ import {Text} from '#/components/Typography' import {useSimpleVerificationState} from '#/components/verification' import {VerificationCheck} from '#/components/verification/VerificationCheck' +import {useActorStatus} from '#/features/liveNow' export function AccountList({ onSelectAccount, diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -14,7 +14,6 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useActorStatus} from '#/lib/actor-status' import {getModerationCauseKey} from '#/lib/moderation' import {forceLTR} from '#/lib/strings/bidi' import {NON_BREAKING_SPACE} from '#/lib/strings/constants' @@ -47,6 +46,7 @@ import {useSimpleVerificationState} from '#/components/verification' import {VerificationCheck} from '#/components/verification/VerificationCheck' import {type Metrics} from '#/analytics' +import {useActorStatus} from '#/features/liveNow' import type * as bsky from '#/types/bsky' export function Default({ diff --git a/src/env/common.ts b/src/env/common.ts --- a/src/env/common.ts +++ b/src/env/common.ts @@ -141,3 +141,13 @@ export const LIVE_EVENTS_URL = IS_DEV ? (LIVE_EVENTS_DEV_URL ?? LIVE_EVENTS_PROD_URL) : LIVE_EVENTS_PROD_URL + +/** + * URLs for the app-config web worker. Can be a + * locally running server, see `env.example` for more. + */ +export const APP_CONFIG_DEV_URL = process.env.APP_CONFIG_DEV_URL +export const APP_CONFIG_PROD_URL = `https://app-config.workers.bsky.app` +export const APP_CONFIG_URL = IS_DEV + ? (APP_CONFIG_DEV_URL ?? APP_CONFIG_PROD_URL) + : APP_CONFIG_PROD_URL diff --git a/src/lib/actor-status.ts b/src/lib/actor-status.ts deleted file mode 100644 --- a/src/lib/actor-status.ts +++ /dev/null @@ -1,92 +0,0 @@ -import {useMemo} from 'react' -import { - type $Typed, - type AppBskyActorDefs, - AppBskyEmbedExternal, - AtUri, -} from '@atproto/api' -import {isAfter, parseISO} from 'date-fns' - -import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' -import {type LiveNowConfig, useLiveNowConfig} from '#/state/service-config' -import {useTickEveryMinute} from '#/state/shell' -import type * as bsky from '#/types/bsky' - -export function useActorStatus(actor?: bsky.profile.AnyProfileView) { - const shadowed = useMaybeProfileShadow(actor) - const tick = useTickEveryMinute() - const config = useLiveNowConfig() - - return useMemo(() => { - void tick // revalidate every minute - - if (shadowed && 'status' in shadowed && shadowed.status) { - const isValid = isStatusValidForViewers(shadowed.status, config) - const isDisabled = shadowed.status.isDisabled || false - const isActive = isStatusStillActive(shadowed.status.expiresAt) - if (isValid && !isDisabled && isActive) { - return { - uri: shadowed.status.uri, - cid: shadowed.status.cid, - isDisabled: false, - isActive: true, - status: 'app.bsky.actor.status#live', - embed: shadowed.status.embed as $Typed, // temp_isStatusValid asserts this - expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this - record: shadowed.status.record, - } satisfies AppBskyActorDefs.StatusView - } - return { - uri: shadowed.status.uri, - cid: shadowed.status.cid, - isDisabled, - isActive: false, - status: 'app.bsky.actor.status#live', - embed: shadowed.status.embed as $Typed, // temp_isStatusValid asserts this - expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this - record: shadowed.status.record, - } satisfies AppBskyActorDefs.StatusView - } else { - return { - status: '', - isDisabled: false, - isActive: false, - record: {}, - } satisfies AppBskyActorDefs.StatusView - } - }, [shadowed, config, tick]) -} - -export function isStatusStillActive(timeStr: string | undefined) { - if (!timeStr) return false - const now = new Date() - const expiry = parseISO(timeStr) - - return isAfter(expiry, now) -} - -/** - * Validates whether the live status is valid for display in the app. Does NOT - * validate if the status is valid for the acting user e.g. as they go live. - */ -export function isStatusValidForViewers( - status: AppBskyActorDefs.StatusView, - config: LiveNowConfig, -) { - if (status.status !== 'app.bsky.actor.status#live') return false - if (!status.uri) return false // should not happen, just backwards compat - try { - const {host: liveDid} = new AtUri(status.uri) - if (AppBskyEmbedExternal.isView(status.embed)) { - const url = new URL(status.embed.external.uri) - const exception = config.allowedHostsExceptionsByDid.get(liveDid) - const isValidException = exception ? exception.has(url.hostname) : false - const isValidForAnyone = config.defaultAllowedHosts.has(url.hostname) - return isValidException || isValidForAnyone - } else { - return false - } - } catch { - return false - } -} diff --git a/src/state/appConfig.tsx b/src/state/appConfig.tsx new file mode 100644 --- /dev/null +++ b/src/state/appConfig.tsx @@ -0,0 +1,87 @@ +import {createContext, useContext} from 'react' +import {QueryClient, useQuery} from '@tanstack/react-query' + +import {APP_CONFIG_URL} from '#/env' + +const qc = new QueryClient() +const appConfigQueryKey = ['app-config'] + +/** + * Matches the types defined in our `app-config` worker + */ +type AppConfigResponse = { + liveNow: { + allow: string[] + exceptions: { + did: string + allow: string[] + }[] + } +} + +export const DEFAULT_APP_CONFIG_RESPONSE: AppConfigResponse = { + liveNow: { + allow: [], + exceptions: [], + }, +} + +let fetchAppConfigPromise: Promise | undefined + +async function fetchAppConfig(): Promise { + try { + if (!fetchAppConfigPromise) { + fetchAppConfigPromise = (async () => { + const r = await fetch(`${APP_CONFIG_URL}/config`) + if (!r.ok) throw new Error(await r.text()) + const data = await r.json() + return data + })() + } + return await fetchAppConfigPromise + } catch (e) { + fetchAppConfigPromise = undefined + throw e + } +} + +const Context = createContext(DEFAULT_APP_CONFIG_RESPONSE) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const {data} = useQuery( + { + staleTime: Infinity, + queryKey: appConfigQueryKey, + refetchInterval: query => { + // refetch regularly if fetch failed, otherwise never refetch + return query.state.status === 'error' ? 60e3 : Infinity + }, + async queryFn() { + return fetchAppConfig() + }, + }, + qc, + ) + return ( + + {children} + + ) +} + +export async function prefetchAppConfig() { + try { + const data = await fetchAppConfig() + if (data) { + qc.setQueryData(appConfigQueryKey, data) + } + } catch {} +} + +export function useAppConfig() { + const ctx = useContext(Context) + if (!ctx) { + throw new Error('useAppConfig must be used within a Provider') + } + return ctx +} diff --git a/src/state/service-config.tsx b/src/state/service-config.tsx --- a/src/state/service-config.tsx +++ b/src/state/service-config.tsx @@ -2,27 +2,16 @@ import {useLanguagePrefs} from '#/state/preferences/languages' import {useServiceConfigQuery} from '#/state/queries/service-config' -import {useSession} from '#/state/session' -import {useAnalytics} from '#/analytics' -import {IS_DEV} from '#/env' import {device} from '#/storage' type TrendingContext = { enabled: boolean } -type LiveNowContext = { - did: string - domains: string[] -}[] - const TrendingContext = createContext({ enabled: false, }) TrendingContext.displayName = 'TrendingContext' - -const LiveNowContext = createContext([]) -LiveNowContext.displayName = 'LiveNowContext' const CheckEmailConfirmedContext = createContext(null) @@ -60,74 +49,21 @@ return {enabled} }, [isInitialLoad, config, langPrefs.contentLanguages]) - const liveNow = useMemo(() => config?.liveNow ?? [], [config]) - // probably true, so default to true when loading // if the call fails, the query will set it to false for us const checkEmailConfirmed = config?.checkEmailConfirmed ?? true return ( - - - {children} - - + + {children} + ) } export function useTrendingConfig() { return useContext(TrendingContext) -} - -const DEFAULT_LIVE_ALLOWED_DOMAINS = [ - 'twitch.tv', - 'www.twitch.tv', - 'stream.place', - 'bluecast.app', - 'www.bluecast.app', -] -export type LiveNowConfig = { - currentAccountAllowedHosts: Set - defaultAllowedHosts: Set - allowedHostsExceptionsByDid: Map> -} -export function useLiveNowConfig(): LiveNowConfig { - const ctx = useContext(LiveNowContext) - const canGoLive = useCanGoLive() - const {currentAccount} = useSession() - return useMemo(() => { - const defaultAllowedHosts = new Set(DEFAULT_LIVE_ALLOWED_DOMAINS) - const allowedHostsExceptionsByDid = new Map>() - for (const live of ctx) { - allowedHostsExceptionsByDid.set( - live.did, - new Set(DEFAULT_LIVE_ALLOWED_DOMAINS.concat(live.domains)), - ) - } - if (!currentAccount?.did || !canGoLive) - return { - currentAccountAllowedHosts: new Set(), - defaultAllowedHosts, - allowedHostsExceptionsByDid, - } - const vip = ctx.find(live => live.did === currentAccount.did) - return { - currentAccountAllowedHosts: new Set( - DEFAULT_LIVE_ALLOWED_DOMAINS.concat(vip ? vip.domains : []), - ), - defaultAllowedHosts, - allowedHostsExceptionsByDid, - } - }, [ctx, currentAccount, canGoLive]) -} - -export function useCanGoLive() { - const ax = useAnalytics() - const {hasSession} = useSession() - if (!hasSession) return false - return IS_DEV ? true : !ax.features.enabled(ax.features.LiveNowBetaDisable) } export function useCheckEmailConfirmed() { diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -10,7 +10,6 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' -import {useActorStatus} from '#/lib/actor-status' import {getModerationCauseKey} from '#/lib/moderation' import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' @@ -34,7 +33,6 @@ shouldShowKnownFollowers, } from '#/components/KnownFollowers' import {InlineLinkText, Link} from '#/components/Link' -import {LiveStatus} from '#/components/live/LiveStatusDialog' import {Loader} from '#/components/Loader' import * as Pills from '#/components/Pills' import {Portal} from '#/components/Portal' @@ -43,6 +41,8 @@ import {useSimpleVerificationState} from '#/components/verification' import {VerificationCheck} from '#/components/verification/VerificationCheck' import {IS_WEB_TOUCH_DEVICE} from '#/env' +import {useActorStatus} from '#/features/liveNow' +import {LiveStatus} from '#/features/liveNow/components/LiveStatusDialog' import {type ProfileHoverCardProps} from './types' const floatingMiddlewares = [ diff --git a/src/components/live/EditLiveDialog.tsx b/src/components/live/EditLiveDialog.tsx deleted file mode 100644 --- a/src/components/live/EditLiveDialog.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import {useMemo, useState} from 'react' -import {View} from 'react-native' -import { - type AppBskyActorDefs, - AppBskyActorStatus, - type AppBskyEmbedExternal, -} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {differenceInMinutes} from 'date-fns' - -import {cleanError} from '#/lib/strings/errors' -import {definitelyUrl} from '#/lib/strings/url-helpers' -import {useTickEveryMinute} from '#/state/shell' -import {atoms as a, platform, useTheme, web} from '#/alf' -import {Admonition} from '#/components/Admonition' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import * as Dialog from '#/components/Dialog' -import * as TextField from '#/components/forms/TextField' -import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock' -import {Loader} from '#/components/Loader' -import {Text} from '#/components/Typography' -import {LinkPreview} from './LinkPreview' -import { - useLiveLinkMetaQuery, - useRemoveLiveStatusMutation, - useUpsertLiveStatusMutation, -} from './queries' -import {displayDuration, useDebouncedValue} from './utils' - -export function EditLiveDialog({ - control, - status, - embed, -}: { - control: Dialog.DialogControlProps - status: AppBskyActorDefs.StatusView - embed: AppBskyEmbedExternal.View -}) { - return ( - - - - - ) -} - -function DialogInner({ - status, - embed, -}: { - status: AppBskyActorDefs.StatusView - embed: AppBskyEmbedExternal.View -}) { - const control = Dialog.useDialogContext() - const {_, i18n} = useLingui() - const t = useTheme() - - const [liveLink, setLiveLink] = useState(embed.external.uri) - const [liveLinkError, setLiveLinkError] = useState('') - const tick = useTickEveryMinute() - - const liveLinkUrl = definitelyUrl(liveLink) - const debouncedUrl = useDebouncedValue(liveLinkUrl, 500) - - const isDirty = liveLinkUrl !== embed.external.uri - - const { - data: linkMeta, - isSuccess: hasValidLinkMeta, - isLoading: linkMetaLoading, - error: linkMetaError, - } = useLiveLinkMetaQuery(debouncedUrl) - - const record = useMemo(() => { - if (!AppBskyActorStatus.isRecord(status.record)) return null - const validation = AppBskyActorStatus.validateRecord(status.record) - if (validation.success) { - return validation.value - } - return null - }, [status]) - - const { - mutate: goLive, - isPending: isGoingLive, - error: goLiveError, - } = useUpsertLiveStatusMutation( - record?.durationMinutes ?? 0, - linkMeta, - record?.createdAt, - ) - - const { - mutate: removeLiveStatus, - isPending: isRemovingLiveStatus, - error: removeLiveStatusError, - } = useRemoveLiveStatusMutation() - - const {minutesUntilExpiry, expiryDateTime} = useMemo(() => { - void tick - - const expiry = new Date(status.expiresAt ?? new Date()) - return { - expiryDateTime: expiry, - minutesUntilExpiry: differenceInMinutes(expiry, new Date()), - } - }, [tick, status.expiresAt]) - - const submitDisabled = - isGoingLive || - !hasValidLinkMeta || - debouncedUrl !== liveLinkUrl || - isRemovingLiveStatus - - return ( - - - - - You are Live - - - - - {typeof record?.durationMinutes === 'number' ? ( - - Expires in {displayDuration(i18n, minutesUntilExpiry)} at{' '} - {i18n.date(expiryDateTime, { - hour: 'numeric', - minute: '2-digit', - hour12: true, - })} - - ) : ( - No expiry set - )} - - - - - - - Live link - - - setLiveLinkError('')} - onBlur={() => { - if (!definitelyUrl(liveLink)) { - setLiveLinkError('Invalid URL') - } - }} - returnKeyType="done" - autoCapitalize="none" - autoComplete="url" - autoCorrect={false} - onSubmitEditing={() => { - if (isDirty && !submitDisabled) { - goLive() - } - }} - /> - - - {(liveLinkError || linkMetaError) && ( - - {liveLinkError ? ( - This is not a valid link - ) : ( - cleanError(linkMetaError) - )} - - )} - - - - - {goLiveError && ( - {cleanError(goLiveError)} - )} - {removeLiveStatusError && ( - - {cleanError(removeLiveStatusError)} - - )} - - - {isDirty ? ( - - ) : ( - - )} - - - - - - ) -} diff --git a/src/components/live/GoLiveDialog.tsx b/src/components/live/GoLiveDialog.tsx deleted file mode 100644 --- a/src/components/live/GoLiveDialog.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import {useCallback, useState} from 'react' -import {View} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {cleanError} from '#/lib/strings/errors' -import {definitelyUrl} from '#/lib/strings/url-helpers' -import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useLiveNowConfig} from '#/state/service-config' -import {useTickEveryMinute} from '#/state/shell' -import {atoms as a, ios, native, platform, useTheme, web} from '#/alf' -import {Admonition} from '#/components/Admonition' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import * as Dialog from '#/components/Dialog' -import * as TextField from '#/components/forms/TextField' -import { - displayDuration, - getLiveServiceNames, - useDebouncedValue, -} from '#/components/live/utils' -import {Loader} from '#/components/Loader' -import * as ProfileCard from '#/components/ProfileCard' -import * as Select from '#/components/Select' -import {Text} from '#/components/Typography' -import type * as bsky from '#/types/bsky' -import {LinkPreview} from './LinkPreview' -import {useLiveLinkMetaQuery, useUpsertLiveStatusMutation} from './queries' - -export function GoLiveDialog({ - control, - profile, -}: { - control: Dialog.DialogControlProps - profile: bsky.profile.AnyProfileView -}) { - return ( - - - - - ) -} - -// Possible durations: max 4 hours, 5 minute intervals -const DURATIONS = Array.from({length: (4 * 60) / 5}).map((_, i) => (i + 1) * 5) - -function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) { - const control = Dialog.useDialogContext() - const {_, i18n} = useLingui() - const t = useTheme() - const [liveLink, setLiveLink] = useState('') - const [liveLinkError, setLiveLinkError] = useState('') - const [duration, setDuration] = useState(60) - const moderationOpts = useModerationOpts() - const tick = useTickEveryMinute() - const liveNowConfig = useLiveNowConfig() - const {formatted: allowedServices} = getLiveServiceNames( - liveNowConfig.currentAccountAllowedHosts, - ) - - const time = useCallback( - (offset: number) => { - void tick - - const date = new Date() - date.setMinutes(date.getMinutes() + offset) - return i18n.date(date, {hour: 'numeric', minute: '2-digit', hour12: true}) - }, - [tick, i18n], - ) - - const onChangeDuration = useCallback((newDuration: string) => { - setDuration(Number(newDuration)) - }, []) - - const liveLinkUrl = definitelyUrl(liveLink) - const debouncedUrl = useDebouncedValue(liveLinkUrl, 500) - - const { - data: linkMeta, - isSuccess: hasValidLinkMeta, - isLoading: linkMetaLoading, - error: linkMetaError, - } = useLiveLinkMetaQuery(debouncedUrl) - - const { - mutate: goLive, - isPending: isGoingLive, - error: goLiveError, - } = useUpsertLiveStatusMutation(duration, linkMeta) - - const isSourceInvalid = !!liveLinkError || !!linkMetaError - - const hasLink = !!debouncedUrl && !isSourceInvalid - - return ( - - - - - Go Live - - - - Add a temporary live status to your profile. When someone clicks - on your avatar, they’ll see information about your live event. - - - - {moderationOpts && ( - - - - - )} - - - - Live link - - - setLiveLinkError('')} - onBlur={() => { - if (!definitelyUrl(liveLink)) { - setLiveLinkError('Invalid URL') - } - }} - returnKeyType="done" - autoCapitalize="none" - autoComplete="url" - autoCorrect={false} - /> - - - {liveLinkError || linkMetaError ? ( - - {liveLinkError ? ( - This is not a valid link - ) : ( - cleanError(linkMetaError) - )} - - ) : ( - - - The following services are enabled for your account:{' '} - {allowedServices} - - - )} - - - - - {hasLink && ( - - - Go live for - - - - - {displayDuration(i18n, duration)} - {' '} - - {time(duration)} - - - - - - { - const label = displayDuration(i18n, item) - return ( - - - - {label} - {' '} - - {time(item)} - - - - ) - }} - items={DURATIONS} - valueExtractor={d => String(d)} - /> - - - )} - - {goLiveError && ( - {cleanError(goLiveError)} - )} - - - {hasLink && ( - - )} - - - - - - ) -} diff --git a/src/components/live/GoLiveDisabledDialog.tsx b/src/components/live/GoLiveDisabledDialog.tsx deleted file mode 100644 --- a/src/components/live/GoLiveDisabledDialog.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import {useCallback, useState} from 'react' -import {View} from 'react-native' -import {type AppBskyActorDefs, ToolsOzoneReportDefs} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useMutation} from '@tanstack/react-query' - -import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants' -import {logger} from '#/logger' -import {useAgent} from '#/state/session' -import {atoms as a, web} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import * as Dialog from '#/components/Dialog' -import {Loader} from '#/components/Loader' -import * as Toast from '#/components/Toast' -import {Text} from '#/components/Typography' - -export function GoLiveDisabledDialog({ - control, - status, -}: { - control: Dialog.DialogControlProps - status: AppBskyActorDefs.StatusView -}) { - return ( - - - - - ) -} - -export function DialogInner({ - control, - status, -}: { - control: Dialog.DialogControlProps - status: AppBskyActorDefs.StatusView -}) { - const {_} = useLingui() - const agent = useAgent() - const [details, setDetails] = useState('') - - const {mutate, isPending} = useMutation({ - mutationFn: async () => { - if (!agent.session?.did) { - throw new Error('Not logged in') - } - if (!status.uri || !status.cid) { - throw new Error('Status is missing uri or cid') - } - - if (__DEV__) { - logger.info('Submitting go live appeal', { - details, - }) - } else { - await agent.createModerationReport( - { - reasonType: ToolsOzoneReportDefs.REASONAPPEAL, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: status.uri, - cid: status.cid, - }, - reason: details, - }, - { - encoding: 'application/json', - headers: BLUESKY_MOD_SERVICE_HEADERS, - }, - ) - } - }, - onError: () => { - Toast.show(_(msg`Failed to submit appeal, please try again.`), { - type: 'error', - }) - }, - onSuccess: () => { - control.close() - Toast.show(_(msg({message: 'Appeal submitted', context: 'toast'})), { - type: 'success', - }) - }, - }) - - const onSubmit = useCallback(() => mutate(), [mutate]) - - return ( - - - - - Going live is currently disabled for your account - - - - You are currently blocked from using the Go Live feature. To - appeal this moderation decision, please submit the form below. - - - - - This appeal will be sent to Bluesky's moderation service. - - - - - - - - - - - - ) -} diff --git a/src/components/live/LinkPreview.tsx b/src/components/live/LinkPreview.tsx deleted file mode 100644 --- a/src/components/live/LinkPreview.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import {useState} from 'react' -import {View} from 'react-native' -import {Image} from 'expo-image' -import {Trans} from '@lingui/macro' - -import {type LinkMeta} from '#/lib/link-meta/link-meta' -import {toNiceDomain} from '#/lib/strings/url-helpers' -import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' -import {atoms as a, useTheme} from '#/alf' -import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' -import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' -import {Text} from '#/components/Typography' - -export function LinkPreview({ - linkMeta, - loading, -}: { - linkMeta?: LinkMeta - loading: boolean -}) { - const t = useTheme() - const [imageLoadError, setImageLoadError] = useState(false) - - if (!linkMeta && !loading) { - return null - } - - return ( - - - {linkMeta?.image && ( - setImageLoadError(false)} - onError={() => setImageLoadError(true)} - /> - )} - {linkMeta && (!linkMeta.image || imageLoadError) && ( - <> - - - No image - - - )} - - - {linkMeta ? ( - <> - - {linkMeta.title || linkMeta.url} - - - - - {toNiceDomain(linkMeta.url)} - - - - ) : ( - <> - - - - )} - - - ) -} diff --git a/src/components/live/LiveIndicator.tsx b/src/components/live/LiveIndicator.tsx deleted file mode 100644 --- a/src/components/live/LiveIndicator.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import {type StyleProp, View, type ViewStyle} from 'react-native' -import {Trans} from '@lingui/macro' - -import {atoms as a, tokens, useTheme} from '#/alf' -import {Text} from '#/components/Typography' - -export function LiveIndicator({ - size = 'small', - style, -}: { - size?: 'tiny' | 'small' | 'large' - style?: StyleProp -}) { - const t = useTheme() - - const fontSize = { - tiny: {fontSize: 7, letterSpacing: tokens.TRACKING}, - small: a.text_2xs, - large: a.text_xs, - }[size] - - return ( - - - - - LIVE - - - - - ) -} diff --git a/src/components/live/LiveStatusDialog.tsx b/src/components/live/LiveStatusDialog.tsx deleted file mode 100644 --- a/src/components/live/LiveStatusDialog.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import {useCallback} from 'react' -import {View} from 'react-native' -import {Image} from 'expo-image' -import {type AppBskyActorDefs, type AppBskyEmbedExternal} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/native' -import {useQueryClient} from '@tanstack/react-query' - -import {useOpenLink} from '#/lib/hooks/useOpenLink' -import {type NavigationProp} from '#/lib/routes/types' -import {sanitizeHandle} from '#/lib/strings/handles' -import {toNiceDomain} from '#/lib/strings/url-helpers' -import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {unstableCacheProfileView} from '#/state/queries/profile' -import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import * as Dialog from '#/components/Dialog' -import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' -import {createStaticClick, SimpleInlineLinkText} from '#/components/Link' -import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog' -import * as ProfileCard from '#/components/ProfileCard' -import {Text} from '#/components/Typography' -import {useAnalytics} from '#/analytics' -import type * as bsky from '#/types/bsky' -import {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe' -import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight' -import {LiveIndicator} from './LiveIndicator' - -export function LiveStatusDialog({ - control, - profile, - embed, - status, -}: { - control: Dialog.DialogControlProps - profile: bsky.profile.AnyProfileView - status: AppBskyActorDefs.StatusView - embed: AppBskyEmbedExternal.View -}) { - const navigation = useNavigation() - return ( - - - - - ) -} - -function DialogInner({ - profile, - embed, - navigation, - status, -}: { - profile: bsky.profile.AnyProfileView - embed: AppBskyEmbedExternal.View - navigation: NavigationProp - status: AppBskyActorDefs.StatusView -}) { - const {_} = useLingui() - const control = Dialog.useDialogContext() - - const onPressOpenProfile = useCallback(() => { - control.close(() => { - navigation.push('Profile', { - name: profile.handle, - }) - }) - }, [navigation, profile.handle, control]) - - return ( - - - - - ) -} - -export function LiveStatus({ - status, - profile, - embed, - padding = 'xl', - onPressOpenProfile, -}: { - status: AppBskyActorDefs.StatusView - profile: bsky.profile.AnyProfileView - embed: AppBskyEmbedExternal.View - padding?: 'lg' | 'xl' - onPressOpenProfile: () => void -}) { - const ax = useAnalytics() - const {_} = useLingui() - const t = useTheme() - const queryClient = useQueryClient() - const openLink = useOpenLink() - const moderationOpts = useModerationOpts() - const reportDialogControl = useGlobalReportDialogControl() - const dialogContext = Dialog.useDialogContext() - - return ( - <> - {embed.external.thumb && ( - - - - - )} - - - - {embed.external.title || embed.external.uri} - - - - - {toNiceDomain(embed.external.uri)} - - - - - - {moderationOpts && ( - - - {/* Ensure wide enough on web hover */} - - - - - - )} - - - - - Live feature is in beta - - - {status && ( - { - function open() { - reportDialogControl.open({ - subject: { - ...status, - $type: 'app.bsky.actor.defs#statusView', - }, - }) - } - if (dialogContext.isWithinDialog) { - dialogContext.close(open) - } else { - open() - } - })} - style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]}> - Report - - )} - - - - ) -} diff --git a/src/components/live/queries.ts b/src/components/live/queries.ts deleted file mode 100644 --- a/src/components/live/queries.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { - type $Typed, - type AppBskyActorStatus, - type AppBskyEmbedExternal, - ComAtprotoRepoPutRecord, -} from '@atproto/api' -import {retry} from '@atproto/common-web' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' - -import {uploadBlob} from '#/lib/api' -import {imageToThumb} from '#/lib/api/resolve' -import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' -import {updateProfileShadow} from '#/state/cache/profile-shadow' -import {useLiveNowConfig} from '#/state/service-config' -import {useAgent, useSession} from '#/state/session' -import * as Toast from '#/view/com/util/Toast' -import {useDialogContext} from '#/components/Dialog' -import {getLiveServiceNames} from '#/components/live/utils' -import {useAnalytics} from '#/analytics' - -export function useLiveLinkMetaQuery(url: string | null) { - const liveNowConfig = useLiveNowConfig() - const {_} = useLingui() - - const agent = useAgent() - return useQuery({ - enabled: !!url, - queryKey: ['link-meta', url], - queryFn: async () => { - if (!url) return undefined - const urlp = new URL(url) - if (!liveNowConfig.currentAccountAllowedHosts.has(urlp.hostname)) { - const {formatted} = getLiveServiceNames( - liveNowConfig.currentAccountAllowedHosts, - ) - throw new Error( - _( - msg`This service is not supported while the Live feature is in beta. Allowed services: ${formatted}.`, - ), - ) - } - - return await getLinkMeta(agent, url) - }, - }) -} - -export function useUpsertLiveStatusMutation( - duration: number, - linkMeta: LinkMeta | null | undefined, - createdAt?: string, -) { - const ax = useAnalytics() - const {currentAccount} = useSession() - const agent = useAgent() - const queryClient = useQueryClient() - const control = useDialogContext() - const {_} = useLingui() - - return useMutation({ - mutationFn: async () => { - if (!currentAccount) throw new Error('Not logged in') - - let embed: $Typed | undefined - - if (linkMeta) { - let thumb - - if (linkMeta.image) { - try { - const img = await imageToThumb(linkMeta.image) - if (img) { - const blob = await uploadBlob( - agent, - img.source.path, - img.source.mime, - ) - thumb = blob.data.blob - } - } catch (e: any) { - ax.logger.error(`Failed to upload thumbnail for live status`, { - url: linkMeta.url, - image: linkMeta.image, - safeMessage: e, - }) - } - } - - embed = { - $type: 'app.bsky.embed.external', - external: { - $type: 'app.bsky.embed.external#external', - title: linkMeta.title ?? '', - description: linkMeta.description ?? '', - uri: linkMeta.url, - thumb, - }, - } - } - - const record = { - $type: 'app.bsky.actor.status', - createdAt: createdAt ?? new Date().toISOString(), - status: 'app.bsky.actor.status#live', - durationMinutes: duration, - embed, - } satisfies AppBskyActorStatus.Record - - const upsert = async () => { - const repo = currentAccount.did - const collection = 'app.bsky.actor.status' - - const existing = await agent.com.atproto.repo - .getRecord({repo, collection, rkey: 'self'}) - .catch(_e => undefined) - - await agent.com.atproto.repo.putRecord({ - repo, - collection, - rkey: 'self', - record, - swapRecord: existing?.data.cid || null, - }) - } - - await retry(upsert, { - maxRetries: 5, - retryable: e => e instanceof ComAtprotoRepoPutRecord.InvalidSwapError, - }) - - return { - record, - image: linkMeta?.image, - } - }, - onError: (e: any) => { - ax.logger.error(`Failed to upsert live status`, { - url: linkMeta?.url, - image: linkMeta?.image, - safeMessage: e, - }) - }, - onSuccess: ({record, image}) => { - if (createdAt) { - ax.metric('live:edit', {duration: record.durationMinutes}) - } else { - ax.metric('live:create', {duration: record.durationMinutes}) - } - - Toast.show(_(msg`You are now live!`)) - control.close(() => { - if (!currentAccount) return - - const expiresAt = new Date(record.createdAt) - expiresAt.setMinutes(expiresAt.getMinutes() + record.durationMinutes) - - updateProfileShadow(queryClient, currentAccount.did, { - status: { - $type: 'app.bsky.actor.defs#statusView', - status: 'app.bsky.actor.status#live', - isActive: true, - expiresAt: expiresAt.toISOString(), - embed: - record.embed && image - ? { - $type: 'app.bsky.embed.external#view', - external: { - ...record.embed.external, - $type: 'app.bsky.embed.external#viewExternal', - thumb: image, - }, - } - : undefined, - record, - }, - }) - }) - }, - }) -} - -export function useRemoveLiveStatusMutation() { - const ax = useAnalytics() - const {currentAccount} = useSession() - const agent = useAgent() - const queryClient = useQueryClient() - const control = useDialogContext() - const {_} = useLingui() - - return useMutation({ - mutationFn: async () => { - if (!currentAccount) throw new Error('Not logged in') - - await agent.app.bsky.actor.status.delete({ - repo: currentAccount.did, - rkey: 'self', - }) - }, - onError: (e: any) => { - ax.logger.error(`Failed to remove live status`, { - safeMessage: e, - }) - }, - onSuccess: () => { - ax.metric('live:remove', {}) - Toast.show(_(msg`You are no longer live`)) - control.close(() => { - if (!currentAccount) return - - updateProfileShadow(queryClient, currentAccount.did, { - status: undefined, - }) - }) - }, - }) -} diff --git a/src/components/live/utils.ts b/src/components/live/utils.ts deleted file mode 100644 --- a/src/components/live/utils.ts +++ /dev/null @@ -1,64 +0,0 @@ -import {useEffect, useState} from 'react' -import {type I18n} from '@lingui/core' -import {plural} from '@lingui/macro' - -export function displayDuration(i18n: I18n, durationInMinutes: number) { - const roundedDurationInMinutes = Math.round(durationInMinutes) - const hours = Math.floor(roundedDurationInMinutes / 60) - const minutes = roundedDurationInMinutes % 60 - const minutesString = i18n._( - plural(minutes, {one: '# minute', other: '# minutes'}), - ) - return hours > 0 - ? i18n._( - minutes > 0 - ? plural(hours, { - one: `# hour ${minutesString}`, - other: `# hours ${minutesString}`, - }) - : plural(hours, { - one: '# hour', - other: '# hours', - }), - ) - : minutesString -} - -// Trailing debounce -export function useDebouncedValue(val: T, delayMs: number): T { - const [prev, setPrev] = useState(val) - - useEffect(() => { - const timeout = setTimeout(() => setPrev(val), delayMs) - return () => clearTimeout(timeout) - }, [val, delayMs]) - - return prev -} - -const serviceUrlToNameMap: Record = { - 'twitch.tv': 'Twitch', - 'www.twitch.tv': 'Twitch', - 'youtube.com': 'YouTube', - 'www.youtube.com': 'YouTube', - 'youtu.be': 'YouTube', - 'nba.com': 'NBA', - 'www.nba.com': 'NBA', - 'nba.smart.link': 'nba.smart.link', - 'espn.com': 'ESPN', - 'www.espn.com': 'ESPN', - 'stream.place': 'Streamplace', - 'skylight.social': 'Skylight', - 'bluecast.app': 'Bluecast', - 'www.bluecast.app': 'Bluecast', -} - -export function getLiveServiceNames(domains: Set) { - const names = Array.from( - new Set(Array.from(domains.values()).map(d => serviceUrlToNameMap[d] || d)), - ) - return { - names, - formatted: names.join(', '), - } -} diff --git a/src/features/liveNow/index.tsx b/src/features/liveNow/index.tsx new file mode 100644 --- /dev/null +++ b/src/features/liveNow/index.tsx @@ -0,0 +1,360 @@ +import {useMemo} from 'react' +import { + type $Typed, + type AppBskyActorDefs, + type AppBskyActorStatus, + AppBskyEmbedExternal, + AtUri, + ComAtprotoRepoPutRecord, +} from '@atproto/api' +import {retry} from '@atproto/common-web' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {isAfter, parseISO} from 'date-fns' + +import {uploadBlob} from '#/lib/api' +import {imageToThumb} from '#/lib/api/resolve' +import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' +import {useAppConfig} from '#/state/appConfig' +import { + updateProfileShadow, + useMaybeProfileShadow, +} from '#/state/cache/profile-shadow' +import {useAgent, useSession} from '#/state/session' +import {useTickEveryMinute} from '#/state/shell' +import * as Toast from '#/view/com/util/Toast' +import {useDialogContext} from '#/components/Dialog' +import {useAnalytics} from '#/analytics' +import {getLiveNowHost, getLiveServiceNames} from '#/features/liveNow/utils' +import type * as bsky from '#/types/bsky' + +export * from '#/features/liveNow/utils' + +export const DEFAULT_ALLOWED_DOMAINS = [ + 'twitch.tv', + 'stream.place', + 'bluecast.app', +] + +export type LiveNowConfig = { + canGoLive: boolean + currentAccountAllowedHosts: Set + defaultAllowedHosts: Set + allowedHostsExceptionsByDid: Map> +} + +export function useLiveNowConfig(): LiveNowConfig { + const ax = useAnalytics() + const {liveNow} = useAppConfig() + const {currentAccount} = useSession() + + return useMemo(() => { + const disabled = ax.features.enabled(ax.features.LiveNowBetaDisable) + + const defaultAllowedHosts = new Set( + DEFAULT_ALLOWED_DOMAINS.concat(liveNow.allow), + ) + const allowedHostsExceptionsByDid = new Map>() + for (const ex of liveNow.exceptions) { + allowedHostsExceptionsByDid.set( + ex.did, + new Set(DEFAULT_ALLOWED_DOMAINS.concat(ex.allow)), + ) + } + + if (!currentAccount?.did || disabled) { + return { + canGoLive: false, + currentAccountAllowedHosts: new Set(), + defaultAllowedHosts, + allowedHostsExceptionsByDid, + } + } + + return { + canGoLive: true, + currentAccountAllowedHosts: + allowedHostsExceptionsByDid.get(currentAccount.did) ?? + defaultAllowedHosts, + defaultAllowedHosts, + allowedHostsExceptionsByDid, + } + }, [ax, liveNow, currentAccount]) +} + +export function useActorStatus(actor?: bsky.profile.AnyProfileView) { + const shadowed = useMaybeProfileShadow(actor) + const tick = useTickEveryMinute() + const config = useLiveNowConfig() + + return useMemo(() => { + void tick // revalidate every minute + + if (shadowed && 'status' in shadowed && shadowed.status) { + const isValid = isStatusValidForViewers(shadowed.status, config) + const isDisabled = shadowed.status.isDisabled || false + const isActive = isStatusStillActive(shadowed.status.expiresAt) + if (isValid && !isDisabled && isActive) { + return { + uri: shadowed.status.uri, + cid: shadowed.status.cid, + isDisabled: false, + isActive: true, + status: 'app.bsky.actor.status#live', + embed: shadowed.status.embed as $Typed, // temp_isStatusValid asserts this + expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this + record: shadowed.status.record, + } satisfies AppBskyActorDefs.StatusView + } + return { + uri: shadowed.status.uri, + cid: shadowed.status.cid, + isDisabled, + isActive: false, + status: 'app.bsky.actor.status#live', + embed: shadowed.status.embed as $Typed, // temp_isStatusValid asserts this + expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this + record: shadowed.status.record, + } satisfies AppBskyActorDefs.StatusView + } else { + return { + status: '', + isDisabled: false, + isActive: false, + record: {}, + } satisfies AppBskyActorDefs.StatusView + } + }, [shadowed, config, tick]) +} + +export function isStatusStillActive(timeStr: string | undefined) { + if (!timeStr) return false + const now = new Date() + const expiry = parseISO(timeStr) + + return isAfter(expiry, now) +} + +/** + * Validates whether the live status is valid for display in the app. Does NOT + * validate if the status is valid for the acting user e.g. as they go live. + */ +export function isStatusValidForViewers( + status: AppBskyActorDefs.StatusView, + config: LiveNowConfig, +) { + if (status.status !== 'app.bsky.actor.status#live') return false + if (!status.uri) return false // should not happen, just backwards compat + try { + const {host: liveDid} = new AtUri(status.uri) + if (AppBskyEmbedExternal.isView(status.embed)) { + const host = getLiveNowHost(status.embed.external.uri) + const exception = config.allowedHostsExceptionsByDid.get(liveDid) + const isValidException = exception ? exception.has(host) : false + const isValidForAnyone = config.defaultAllowedHosts.has(host) + return isValidException || isValidForAnyone + } else { + return false + } + } catch { + return false + } +} + +export function useLiveLinkMetaQuery(url: string | null) { + const liveNowConfig = useLiveNowConfig() + const {_} = useLingui() + + const agent = useAgent() + return useQuery({ + enabled: !!url, + queryKey: ['link-meta', url], + queryFn: async () => { + if (!url) return undefined + const host = getLiveNowHost(url) + if (!liveNowConfig.currentAccountAllowedHosts.has(host)) { + const {formatted} = getLiveServiceNames( + liveNowConfig.currentAccountAllowedHosts, + ) + throw new Error( + _( + msg`This service is not supported while the Live feature is in beta. Allowed services: ${formatted}.`, + ), + ) + } + + return await getLinkMeta(agent, url) + }, + }) +} + +export function useUpsertLiveStatusMutation( + duration: number, + linkMeta: LinkMeta | null | undefined, + createdAt?: string, +) { + const ax = useAnalytics() + const {currentAccount} = useSession() + const agent = useAgent() + const queryClient = useQueryClient() + const control = useDialogContext() + const {_} = useLingui() + + return useMutation({ + mutationFn: async () => { + if (!currentAccount) throw new Error('Not logged in') + + let embed: $Typed | undefined + + if (linkMeta) { + let thumb + + if (linkMeta.image) { + try { + const img = await imageToThumb(linkMeta.image) + if (img) { + const blob = await uploadBlob( + agent, + img.source.path, + img.source.mime, + ) + thumb = blob.data.blob + } + } catch (e: any) { + ax.logger.error(`Failed to upload thumbnail for live status`, { + url: linkMeta.url, + image: linkMeta.image, + safeMessage: e, + }) + } + } + + embed = { + $type: 'app.bsky.embed.external', + external: { + $type: 'app.bsky.embed.external#external', + title: linkMeta.title ?? '', + description: linkMeta.description ?? '', + uri: linkMeta.url, + thumb, + }, + } + } + + const record = { + $type: 'app.bsky.actor.status', + createdAt: createdAt ?? new Date().toISOString(), + status: 'app.bsky.actor.status#live', + durationMinutes: duration, + embed, + } satisfies AppBskyActorStatus.Record + + const upsert = async () => { + const repo = currentAccount.did + const collection = 'app.bsky.actor.status' + + const existing = await agent.com.atproto.repo + .getRecord({repo, collection, rkey: 'self'}) + .catch(_e => undefined) + + await agent.com.atproto.repo.putRecord({ + repo, + collection, + rkey: 'self', + record, + swapRecord: existing?.data.cid || null, + }) + } + + await retry(upsert, { + maxRetries: 5, + retryable: e => e instanceof ComAtprotoRepoPutRecord.InvalidSwapError, + }) + + return { + record, + image: linkMeta?.image, + } + }, + onError: (e: any) => { + ax.logger.error(`Failed to upsert live status`, { + url: linkMeta?.url, + image: linkMeta?.image, + safeMessage: e, + }) + }, + onSuccess: ({record, image}) => { + if (createdAt) { + ax.metric('live:edit', {duration: record.durationMinutes}) + } else { + ax.metric('live:create', {duration: record.durationMinutes}) + } + + Toast.show(_(msg`You are now live!`)) + control.close(() => { + if (!currentAccount) return + + const expiresAt = new Date(record.createdAt) + expiresAt.setMinutes(expiresAt.getMinutes() + record.durationMinutes) + + updateProfileShadow(queryClient, currentAccount.did, { + status: { + $type: 'app.bsky.actor.defs#statusView', + status: 'app.bsky.actor.status#live', + isActive: true, + expiresAt: expiresAt.toISOString(), + embed: + record.embed && image + ? { + $type: 'app.bsky.embed.external#view', + external: { + ...record.embed.external, + $type: 'app.bsky.embed.external#viewExternal', + thumb: image, + }, + } + : undefined, + record, + }, + }) + }) + }, + }) +} + +export function useRemoveLiveStatusMutation() { + const ax = useAnalytics() + const {currentAccount} = useSession() + const agent = useAgent() + const queryClient = useQueryClient() + const control = useDialogContext() + const {_} = useLingui() + + return useMutation({ + mutationFn: async () => { + if (!currentAccount) throw new Error('Not logged in') + + await agent.app.bsky.actor.status.delete({ + repo: currentAccount.did, + rkey: 'self', + }) + }, + onError: (e: any) => { + ax.logger.error(`Failed to remove live status`, { + safeMessage: e, + }) + }, + onSuccess: () => { + ax.metric('live:remove', {}) + Toast.show(_(msg`You are no longer live`)) + control.close(() => { + if (!currentAccount) return + + updateProfileShadow(queryClient, currentAccount.did, { + status: undefined, + }) + }) + }, + }) +} diff --git a/src/features/liveNow/utils.ts b/src/features/liveNow/utils.ts new file mode 100644 --- /dev/null +++ b/src/features/liveNow/utils.ts @@ -0,0 +1,72 @@ +import {type I18n} from '@lingui/core' +import {plural} from '@lingui/macro' +import psl from 'psl' + +export function displayDuration(i18n: I18n, durationInMinutes: number) { + const roundedDurationInMinutes = Math.round(durationInMinutes) + const hours = Math.floor(roundedDurationInMinutes / 60) + const minutes = roundedDurationInMinutes % 60 + const minutesString = i18n._( + plural(minutes, {one: '# minute', other: '# minutes'}), + ) + return hours > 0 + ? i18n._( + minutes > 0 + ? plural(hours, { + one: `# hour ${minutesString}`, + other: `# hours ${minutesString}`, + }) + : plural(hours, { + one: '# hour', + other: '# hours', + }), + ) + : minutesString +} + +const serviceUrlToNameMap: Record = { + 'twitch.tv': 'Twitch', + 'youtube.com': 'YouTube', + 'nba.com': 'NBA', + 'nba.smart.link': 'nba.smart.link', + 'espn.com': 'ESPN', + 'stream.place': 'Streamplace', + 'skylight.social': 'Skylight', + 'bluecast.app': 'Bluecast', +} + +export function getLiveServiceNames(domains: Set) { + const names = Array.from( + new Set( + Array.from(domains.values()) + .map(d => sanitizeLiveNowHost(d)) + .map(d => serviceUrlToNameMap[d] || d), + ), + ) + return { + names, + formatted: names.join(', '), + } +} + +export function sanitizeLiveNowHost(hostname: string) { + // special case this one + if (hostname === 'nba.smart.link') { + return hostname + } + const parsed = psl.parse(hostname) + if (parsed.error || !parsed.listed || !parsed.domain) { + // fall back to dumb version + return hostname.replace(/^www\./, '') + } + return parsed.domain +} + +/** + * Extracts the apex domain from a given URL, for use when matching allowed + * Live Now hosts. + */ +export function getLiveNowHost(url: string) { + const {hostname} = new URL(url) + return sanitizeLiveNowHost(hostname) +} diff --git a/src/lib/async/retry.ts b/src/lib/async/retry.ts --- a/src/lib/async/retry.ts +++ b/src/lib/async/retry.ts @@ -29,6 +29,7 @@ export async function networkRetry

( retries: number, fn: () => Promise

, + delay?: number, ): Promise

{ - return retry(retries, isNetworkError, fn) + return retry(retries, isNetworkError, fn, delay) } diff --git a/src/lib/hooks/useDebouncedValue.ts b/src/lib/hooks/useDebouncedValue.ts new file mode 100644 --- /dev/null +++ b/src/lib/hooks/useDebouncedValue.ts @@ -0,0 +1,16 @@ +import {useEffect, useState} from 'react' + +/** + * Returns a debounced version of the input value that only updates after the + * specified delay has passed without any changes to the input value. + */ +export function useDebouncedValue(val: T, delayMs: number): T { + const [prev, setPrev] = useState(val) + + useEffect(() => { + const timeout = setTimeout(() => setPrev(val), delayMs) + return () => clearTimeout(timeout) + }, [val, delayMs]) + + return prev +} diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -7,7 +7,6 @@ import {useNavigation} from '@react-navigation/native' import {type NativeStackScreenProps} from '@react-navigation/native-stack' -import {useActorStatus} from '#/lib/actor-status' import {HELP_DESK_URL} from '#/lib/constants' import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' import {useApplyPullRequestOTAUpdate} from '#/lib/hooks/useOTAUpdates' @@ -70,6 +69,7 @@ } from '#/components/verification/VerificationCheckButton' import {useAnalytics} from '#/analytics' import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env' +import {useActorStatus} from '#/features/liveNow' import {device, useStorage} from '#/storage' import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged' diff --git a/src/state/queries/handle-availability.ts b/src/state/queries/handle-availability.ts --- a/src/state/queries/handle-availability.ts +++ b/src/state/queries/handle-availability.ts @@ -6,8 +6,8 @@ BSKY_SERVICE_DID, PUBLIC_BSKY_SERVICE, } from '#/lib/constants' +import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue' import {createFullHandle} from '#/lib/strings/handles' -import {useDebouncedValue} from '#/components/live/utils' import {useAnalytics} from '#/analytics' import * as bsky from '#/types/bsky' import {Agent} from '../session/agent' diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react' import {StackActions, useNavigation} from '@react-navigation/native' -import {useActorStatus} from '#/lib/actor-status' import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants' import {type PressableScale} from '#/lib/custom-animations/PressableScale' import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState' @@ -57,6 +56,7 @@ import {useSimpleVerificationState} from '#/components/verification' import {VerificationCheck} from '#/components/verification/VerificationCheck' import {IS_WEB} from '#/env' +import {useActorStatus} from '#/features/liveNow' const iconWidth = 26 diff --git a/src/features/liveNow/components/EditLiveDialog.tsx b/src/features/liveNow/components/EditLiveDialog.tsx new file mode 100644 --- /dev/null +++ b/src/features/liveNow/components/EditLiveDialog.tsx @@ -0,0 +1,243 @@ +import {useMemo, useState} from 'react' +import {View} from 'react-native' +import { + type AppBskyActorDefs, + AppBskyActorStatus, + type AppBskyEmbedExternal, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {differenceInMinutes} from 'date-fns' + +import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue' +import {cleanError} from '#/lib/strings/errors' +import {definitelyUrl} from '#/lib/strings/url-helpers' +import {useTickEveryMinute} from '#/state/shell' +import {atoms as a, platform, useTheme, web} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' +import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import { + displayDuration, + useLiveLinkMetaQuery, + useRemoveLiveStatusMutation, + useUpsertLiveStatusMutation, +} from '#/features/liveNow' +import {LinkPreview} from '#/features/liveNow/components/LinkPreview' + +export function EditLiveDialog({ + control, + status, + embed, +}: { + control: Dialog.DialogControlProps + status: AppBskyActorDefs.StatusView + embed: AppBskyEmbedExternal.View +}) { + return ( + + + + + ) +} + +function DialogInner({ + status, + embed, +}: { + status: AppBskyActorDefs.StatusView + embed: AppBskyEmbedExternal.View +}) { + const control = Dialog.useDialogContext() + const {_, i18n} = useLingui() + const t = useTheme() + + const [liveLink, setLiveLink] = useState(embed.external.uri) + const [liveLinkError, setLiveLinkError] = useState('') + const tick = useTickEveryMinute() + + const liveLinkUrl = definitelyUrl(liveLink) + const debouncedUrl = useDebouncedValue(liveLinkUrl, 500) + + const isDirty = liveLinkUrl !== embed.external.uri + + const { + data: linkMeta, + isSuccess: hasValidLinkMeta, + isLoading: linkMetaLoading, + error: linkMetaError, + } = useLiveLinkMetaQuery(debouncedUrl) + + const record = useMemo(() => { + if (!AppBskyActorStatus.isRecord(status.record)) return null + const validation = AppBskyActorStatus.validateRecord(status.record) + if (validation.success) { + return validation.value + } + return null + }, [status]) + + const { + mutate: goLive, + isPending: isGoingLive, + error: goLiveError, + } = useUpsertLiveStatusMutation( + record?.durationMinutes ?? 0, + linkMeta, + record?.createdAt, + ) + + const { + mutate: removeLiveStatus, + isPending: isRemovingLiveStatus, + error: removeLiveStatusError, + } = useRemoveLiveStatusMutation() + + const {minutesUntilExpiry, expiryDateTime} = useMemo(() => { + void tick + + const expiry = new Date(status.expiresAt ?? new Date()) + return { + expiryDateTime: expiry, + minutesUntilExpiry: differenceInMinutes(expiry, new Date()), + } + }, [tick, status.expiresAt]) + + const submitDisabled = + isGoingLive || + !hasValidLinkMeta || + debouncedUrl !== liveLinkUrl || + isRemovingLiveStatus + + return ( + + + + + You are Live + + + + + {typeof record?.durationMinutes === 'number' ? ( + + Expires in {displayDuration(i18n, minutesUntilExpiry)} at{' '} + {i18n.date(expiryDateTime, { + hour: 'numeric', + minute: '2-digit', + hour12: true, + })} + + ) : ( + No expiry set + )} + + + + + + + Live link + + + setLiveLinkError('')} + onBlur={() => { + if (!definitelyUrl(liveLink)) { + setLiveLinkError('Invalid URL') + } + }} + returnKeyType="done" + autoCapitalize="none" + autoComplete="url" + autoCorrect={false} + onSubmitEditing={() => { + if (isDirty && !submitDisabled) { + goLive() + } + }} + /> + + + {(liveLinkError || linkMetaError) && ( + + {liveLinkError ? ( + This is not a valid link + ) : ( + cleanError(linkMetaError) + )} + + )} + + + + + {goLiveError && ( + {cleanError(goLiveError)} + )} + {removeLiveStatusError && ( + + {cleanError(removeLiveStatusError)} + + )} + + + {isDirty ? ( + + ) : ( + + )} + + + + + + ) +} diff --git a/src/features/liveNow/components/GoLiveDialog.tsx b/src/features/liveNow/components/GoLiveDialog.tsx new file mode 100644 --- /dev/null +++ b/src/features/liveNow/components/GoLiveDialog.tsx @@ -0,0 +1,261 @@ +import {useCallback, useState} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue' +import {cleanError} from '#/lib/strings/errors' +import {definitelyUrl} from '#/lib/strings/url-helpers' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useTickEveryMinute} from '#/state/shell' +import {atoms as a, ios, native, platform, useTheme, web} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' +import {Loader} from '#/components/Loader' +import * as ProfileCard from '#/components/ProfileCard' +import * as Select from '#/components/Select' +import {Text} from '#/components/Typography' +import { + displayDuration, + getLiveServiceNames, + useLiveLinkMetaQuery, + useLiveNowConfig, + useUpsertLiveStatusMutation, +} from '#/features/liveNow' +import type * as bsky from '#/types/bsky' +import {LinkPreview} from './LinkPreview' + +export function GoLiveDialog({ + control, + profile, +}: { + control: Dialog.DialogControlProps + profile: bsky.profile.AnyProfileView +}) { + return ( + + + + + ) +} + +// Possible durations: max 4 hours, 5 minute intervals +const DURATIONS = Array.from({length: (4 * 60) / 5}).map((_, i) => (i + 1) * 5) + +function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) { + const control = Dialog.useDialogContext() + const {_, i18n} = useLingui() + const t = useTheme() + const [liveLink, setLiveLink] = useState('') + const [liveLinkError, setLiveLinkError] = useState('') + const [duration, setDuration] = useState(60) + const moderationOpts = useModerationOpts() + const tick = useTickEveryMinute() + const liveNowConfig = useLiveNowConfig() + const {formatted: allowedServices} = getLiveServiceNames( + liveNowConfig.currentAccountAllowedHosts, + ) + + const time = useCallback( + (offset: number) => { + void tick + + const date = new Date() + date.setMinutes(date.getMinutes() + offset) + return i18n.date(date, {hour: 'numeric', minute: '2-digit', hour12: true}) + }, + [tick, i18n], + ) + + const onChangeDuration = useCallback((newDuration: string) => { + setDuration(Number(newDuration)) + }, []) + + const liveLinkUrl = definitelyUrl(liveLink) + const debouncedUrl = useDebouncedValue(liveLinkUrl, 500) + + const { + data: linkMeta, + isSuccess: hasValidLinkMeta, + isLoading: linkMetaLoading, + error: linkMetaError, + } = useLiveLinkMetaQuery(debouncedUrl) + + const { + mutate: goLive, + isPending: isGoingLive, + error: goLiveError, + } = useUpsertLiveStatusMutation(duration, linkMeta) + + const isSourceInvalid = !!liveLinkError || !!linkMetaError + + const hasLink = !!debouncedUrl && !isSourceInvalid + + return ( + + + + + Go Live + + + + Add a temporary live status to your profile. When someone clicks + on your avatar, they’ll see information about your live event. + + + + {moderationOpts && ( + + + + + )} + + + + Live link + + + setLiveLinkError('')} + onBlur={() => { + if (!definitelyUrl(liveLink)) { + setLiveLinkError('Invalid URL') + } + }} + returnKeyType="done" + autoCapitalize="none" + autoComplete="url" + autoCorrect={false} + /> + + + {liveLinkError || linkMetaError ? ( + + {liveLinkError ? ( + This is not a valid link + ) : ( + cleanError(linkMetaError) + )} + + ) : ( + + + The following services are enabled for your account:{' '} + {allowedServices} + + + )} + + + + + {hasLink && ( + + + Go live for + + + + + {displayDuration(i18n, duration)} + {' '} + + {time(duration)} + + + + + + { + const label = displayDuration(i18n, item) + return ( + + + + {label} + {' '} + + {time(item)} + + + + ) + }} + items={DURATIONS} + valueExtractor={d => String(d)} + /> + + + )} + + {goLiveError && ( + {cleanError(goLiveError)} + )} + + + {hasLink && ( + + )} + + + + + + ) +} diff --git a/src/features/liveNow/components/GoLiveDisabledDialog.tsx b/src/features/liveNow/components/GoLiveDisabledDialog.tsx new file mode 100644 --- /dev/null +++ b/src/features/liveNow/components/GoLiveDisabledDialog.tsx @@ -0,0 +1,147 @@ +import {useCallback, useState} from 'react' +import {View} from 'react-native' +import {type AppBskyActorDefs, ToolsOzoneReportDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation} from '@tanstack/react-query' + +import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants' +import {logger} from '#/logger' +import {useAgent} from '#/state/session' +import {atoms as a, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Loader} from '#/components/Loader' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' + +export function GoLiveDisabledDialog({ + control, + status, +}: { + control: Dialog.DialogControlProps + status: AppBskyActorDefs.StatusView +}) { + return ( + + + + + ) +} + +export function DialogInner({ + control, + status, +}: { + control: Dialog.DialogControlProps + status: AppBskyActorDefs.StatusView +}) { + const {_} = useLingui() + const agent = useAgent() + const [details, setDetails] = useState('') + + const {mutate, isPending} = useMutation({ + mutationFn: async () => { + if (!agent.session?.did) { + throw new Error('Not logged in') + } + if (!status.uri || !status.cid) { + throw new Error('Status is missing uri or cid') + } + + if (__DEV__) { + logger.info('Submitting go live appeal', { + details, + }) + } else { + await agent.createModerationReport( + { + reasonType: ToolsOzoneReportDefs.REASONAPPEAL, + subject: { + $type: 'com.atproto.repo.strongRef', + uri: status.uri, + cid: status.cid, + }, + reason: details, + }, + { + encoding: 'application/json', + headers: BLUESKY_MOD_SERVICE_HEADERS, + }, + ) + } + }, + onError: () => { + Toast.show(_(msg`Failed to submit appeal, please try again.`), { + type: 'error', + }) + }, + onSuccess: () => { + control.close() + Toast.show(_(msg({message: 'Appeal submitted', context: 'toast'})), { + type: 'success', + }) + }, + }) + + const onSubmit = useCallback(() => mutate(), [mutate]) + + return ( + + + + + Going live is currently disabled for your account + + + + You are currently blocked from using the Go Live feature. To + appeal this moderation decision, please submit the form below. + + + + + This appeal will be sent to Bluesky's moderation service. + + + + + + + + + + + + ) +} diff --git a/src/features/liveNow/components/LinkPreview.tsx b/src/features/liveNow/components/LinkPreview.tsx new file mode 100644 --- /dev/null +++ b/src/features/liveNow/components/LinkPreview.tsx @@ -0,0 +1,98 @@ +import {useState} from 'react' +import {View} from 'react-native' +import {Image} from 'expo-image' +import {Trans} from '@lingui/macro' + +import {type LinkMeta} from '#/lib/link-meta/link-meta' +import {toNiceDomain} from '#/lib/strings/url-helpers' +import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {atoms as a, useTheme} from '#/alf' +import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' +import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' +import {Text} from '#/components/Typography' + +export function LinkPreview({ + linkMeta, + loading, +}: { + linkMeta?: LinkMeta + loading: boolean +}) { + const t = useTheme() + const [imageLoadError, setImageLoadError] = useState(false) + + if (!linkMeta && !loading) { + return null + } + + return ( + + + {linkMeta?.image && ( + setImageLoadError(false)} + onError={() => setImageLoadError(true)} + /> + )} + {linkMeta && (!linkMeta.image || imageLoadError) && ( + <> + + + No image + + + )} + + + {linkMeta ? ( + <> + + {linkMeta.title || linkMeta.url} + + + + + {toNiceDomain(linkMeta.url)} + + + + ) : ( + <> + + + + )} + + + ) +} diff --git a/src/features/liveNow/components/LiveIndicator.tsx b/src/features/liveNow/components/LiveIndicator.tsx new file mode 100644 --- /dev/null +++ b/src/features/liveNow/components/LiveIndicator.tsx @@ -0,0 +1,53 @@ +import {type StyleProp, View, type ViewStyle} from 'react-native' +import {Trans} from '@lingui/macro' + +import {atoms as a, tokens, useTheme} from '#/alf' +import {Text} from '#/components/Typography' + +export function LiveIndicator({ + size = 'small', + style, +}: { + size?: 'tiny' | 'small' | 'large' + style?: StyleProp +}) { + const t = useTheme() + + const fontSize = { + tiny: {fontSize: 7, letterSpacing: tokens.TRACKING}, + small: a.text_2xs, + large: a.text_xs, + }[size] + + return ( + + + + + LIVE + + + + + ) +} diff --git a/src/features/liveNow/components/LiveStatusDialog.tsx b/src/features/liveNow/components/LiveStatusDialog.tsx new file mode 100644 --- /dev/null +++ b/src/features/liveNow/components/LiveStatusDialog.tsx @@ -0,0 +1,257 @@ +import {useCallback} from 'react' +import {View} from 'react-native' +import {Image} from 'expo-image' +import {type AppBskyActorDefs, type AppBskyEmbedExternal} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' + +import {useOpenLink} from '#/lib/hooks/useOpenLink' +import {type NavigationProp} from '#/lib/routes/types' +import {sanitizeHandle} from '#/lib/strings/handles' +import {toNiceDomain} from '#/lib/strings/url-helpers' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {unstableCacheProfileView} from '#/state/queries/profile' +import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' +import {Globe_Stroke2_Corner0_Rounded} from '#/components/icons/Globe' +import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '#/components/icons/SquareArrowTopRight' +import {createStaticClick, SimpleInlineLinkText} from '#/components/Link' +import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' +import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator' +import type * as bsky from '#/types/bsky' + +export function LiveStatusDialog({ + control, + profile, + embed, + status, +}: { + control: Dialog.DialogControlProps + profile: bsky.profile.AnyProfileView + status: AppBskyActorDefs.StatusView + embed: AppBskyEmbedExternal.View +}) { + const navigation = useNavigation() + return ( + + + + + ) +} + +function DialogInner({ + profile, + embed, + navigation, + status, +}: { + profile: bsky.profile.AnyProfileView + embed: AppBskyEmbedExternal.View + navigation: NavigationProp + status: AppBskyActorDefs.StatusView +}) { + const {_} = useLingui() + const control = Dialog.useDialogContext() + + const onPressOpenProfile = useCallback(() => { + control.close(() => { + navigation.push('Profile', { + name: profile.handle, + }) + }) + }, [navigation, profile.handle, control]) + + return ( + + + + + ) +} + +export function LiveStatus({ + status, + profile, + embed, + padding = 'xl', + onPressOpenProfile, +}: { + status: AppBskyActorDefs.StatusView + profile: bsky.profile.AnyProfileView + embed: AppBskyEmbedExternal.View + padding?: 'lg' | 'xl' + onPressOpenProfile: () => void +}) { + const ax = useAnalytics() + const {_} = useLingui() + const t = useTheme() + const queryClient = useQueryClient() + const openLink = useOpenLink() + const moderationOpts = useModerationOpts() + const reportDialogControl = useGlobalReportDialogControl() + const dialogContext = Dialog.useDialogContext() + + return ( + <> + {embed.external.thumb && ( + + + + + )} + + + + {embed.external.title || embed.external.uri} + + + + + {toNiceDomain(embed.external.uri)} + + + + + + {moderationOpts && ( + + + {/* Ensure wide enough on web hover */} + + + + + + )} + + + + + Live feature is in beta + + + {status && ( + { + function open() { + reportDialogControl.open({ + subject: { + ...status, + $type: 'app.bsky.actor.defs#statusView', + }, + }) + } + if (dialogContext.isWithinDialog) { + dialogContext.close(open) + } else { + open() + } + })} + style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]}> + Report + + )} + + + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -10,7 +10,6 @@ import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useActorStatus} from '#/lib/actor-status' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useTranslate} from '#/lib/hooks/useTranslate' import {makeProfileLink} from '#/lib/routes/links' @@ -60,6 +59,7 @@ import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' import {WhoCanReply} from '#/components/WhoCanReply' import {useAnalytics} from '#/analytics' +import {useActorStatus} from '#/features/liveNow' import * as bsky from '#/types/bsky' export function ThreadItemAnchor({ diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx --- a/src/screens/PostThread/components/ThreadItemPost.tsx +++ b/src/screens/PostThread/components/ThreadItemPost.tsx @@ -8,7 +8,6 @@ } from '@atproto/api' import {Trans} from '@lingui/macro' -import {useActorStatus} from '#/lib/actor-status' import {MAX_POST_LINES} from '#/lib/constants' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {makeProfileLink} from '#/lib/routes/links' @@ -44,6 +43,7 @@ import * as Skele from '#/components/Skeleton' import {SubtleHover} from '#/components/SubtleHover' import {Text} from '#/components/Typography' +import {useActorStatus} from '#/features/liveNow' export type ThreadItemPostProps = { item: Extract diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -10,7 +10,6 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useActorStatus} from '#/lib/actor-status' import {useHaptics} from '#/lib/haptics' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' @@ -39,6 +38,7 @@ import {Text} from '#/components/Typography' import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' import {IS_IOS} from '#/env' +import {useActorStatus} from '#/features/liveNow' import {GermButton} from '../components/GermButton' import {EditProfileDialog} from './EditProfileDialog' import {ProfileHeaderHandle} from './Handle' diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -14,7 +14,6 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' -import {useActorStatus} from '#/lib/actor-status' import {BACK_HITSLOP} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' import {type NavigationProp} from '#/lib/routes/types' @@ -28,13 +27,14 @@ import {Button} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' -import {EditLiveDialog} from '#/components/live/EditLiveDialog' -import {LiveIndicator} from '#/components/live/LiveIndicator' -import {LiveStatusDialog} from '#/components/live/LiveStatusDialog' import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' import {useAnalytics} from '#/analytics' import {IS_IOS} from '#/env' +import {useActorStatus} from '#/features/liveNow' +import {EditLiveDialog} from '#/features/liveNow/components/EditLiveDialog' +import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator' +import {LiveStatusDialog} from '#/features/liveNow/components/LiveStatusDialog' import {GrowableAvatar} from './GrowableAvatar' import {GrowableBanner} from './GrowableBanner' import {StatusBarShadow} from './StatusBarShadow' diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -19,7 +19,6 @@ import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import {isStatusStillActive, isStatusValidForViewers} from '#/lib/actor-status' import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -40,7 +39,6 @@ RQKEY, usePostFeedQuery, } from '#/state/queries/post-feed' -import {useLiveNowConfig} from '#/state/service-config' import {useSession} from '#/state/session' import {useProgressGuide} from '#/state/shell/progress-guide' import {useSelectedFeed} from '#/state/shell/selected-feed' @@ -63,6 +61,11 @@ import {useAnalytics} from '#/analytics' import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env' import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner' +import { + isStatusStillActive, + isStatusValidForViewers, + useLiveNowConfig, +} from '#/features/liveNow' import {ComposerPrompt} from '../feeds/ComposerPrompt' import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' import {FeedShutdownMsg} from './FeedShutdownMsg' diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -11,7 +11,6 @@ } from '@atproto/api' import {useQueryClient} from '@tanstack/react-query' -import {useActorStatus} from '#/lib/actor-status' import {type ReasonFeedSource} from '#/lib/api/feed/types' import {MAX_POST_LINES} from '#/lib/constants' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' @@ -48,6 +47,7 @@ import {RichText} from '#/components/RichText' import {SubtleHover} from '#/components/SubtleHover' import {useAnalytics} from '#/analytics' +import {useActorStatus} from '#/features/liveNow' import * as bsky from '#/types/bsky' import {PostFeedReason} from './PostFeedReason' diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -5,7 +5,6 @@ import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' -import {useActorStatus} from '#/lib/actor-status' import {HITSLOP_20} from '#/lib/constants' import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' @@ -20,7 +19,6 @@ useProfileFollowMutationQueue, useProfileMuteMutationQueue, } from '#/state/queries/profile' -import {useCanGoLive} from '#/state/service-config' import {useSession} from '#/state/session' import {EventStopper} from '#/view/com/util/EventStopper' import * as Toast from '#/view/com/util/Toast' @@ -47,9 +45,6 @@ import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' import {StarterPack} from '#/components/icons/StarterPack' -import {EditLiveDialog} from '#/components/live/EditLiveDialog' -import {GoLiveDialog} from '#/components/live/GoLiveDialog' -import {GoLiveDisabledDialog} from '#/components/live/GoLiveDisabledDialog' import * as Menu from '#/components/Menu' import { ReportDialog, @@ -61,6 +56,10 @@ import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' import {useAnalytics} from '#/analytics' import {IS_WEB} from '#/env' +import {useActorStatus, useLiveNowConfig} from '#/features/liveNow' +import {EditLiveDialog} from '#/features/liveNow/components/EditLiveDialog' +import {GoLiveDialog} from '#/features/liveNow/components/GoLiveDialog' +import {GoLiveDisabledDialog} from '#/features/liveNow/components/GoLiveDisabledDialog' import {Dot} from '#/features/nuxs/components/Dot' import {Gradient} from '#/features/nuxs/components/Gradient' import {useDevMode} from '#/storage/hooks/dev-mode' @@ -85,7 +84,7 @@ const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked const [devModeEnabled] = useDevMode() const verification = useFullVerificationState({profile}) - const canGoLive = useCanGoLive() + const {canGoLive} = useLiveNowConfig() const status = useActorStatus(profile) const statusNudge = useNux(Nux.LiveNowBetaNudge) const statusNudgeActive = diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import {useActorStatus} from '#/lib/actor-status' import {makeProfileLink} from '#/lib/routes/links' import {forceLTR} from '#/lib/strings/bidi' import {NON_BREAKING_SPACE} from '#/lib/strings/constants' @@ -21,6 +20,7 @@ import {useSimpleVerificationState} from '#/components/verification' import {VerificationCheck} from '#/components/verification/VerificationCheck' import {IS_ANDROID} from '#/env' +import {useActorStatus} from '#/features/liveNow' import {TimeElapsed} from './TimeElapsed' import {PreviewableUserAvatar} from './UserAvatar' diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -15,7 +15,6 @@ import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import {useActorStatus} from '#/lib/actor-status' import {useHaptics} from '#/lib/haptics' import { useCameraPermission, @@ -47,13 +46,14 @@ import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {Link} from '#/components/Link' -import {LiveIndicator} from '#/components/live/LiveIndicator' -import {LiveStatusDialog} from '#/components/live/LiveStatusDialog' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import * as Menu from '#/components/Menu' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {useAnalytics} from '#/analytics' import {IS_ANDROID, IS_NATIVE, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' +import {useActorStatus} from '#/features/liveNow' +import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator' +import {LiveStatusDialog} from '#/features/liveNow/components/LiveStatusDialog' import type * as bsky from '#/types/bsky' export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler' diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -7,7 +7,6 @@ import {type BottomTabBarProps} from '@react-navigation/bottom-tabs' import {StackActions} from '@react-navigation/native' -import {useActorStatus} from '#/lib/actor-status' import {PressableScale} from '#/lib/custom-animations/PressableScale' import {BOTTOM_BAR_AVI} from '#/lib/demo' import {useHaptics} from '#/lib/haptics' @@ -49,6 +48,7 @@ Message_Stroke2_Corner0_Rounded_Filled as MessageFilled, } from '#/components/icons/Message' import {Text} from '#/components/Typography' +import {useActorStatus} from '#/features/liveNow' import {useDemoMode} from '#/storage/hooks/demo-mode' import {styles} from './BottomBarStyles' diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react' import {useNavigation, useNavigationState} from '@react-navigation/native' -import {useActorStatus} from '#/lib/actor-status' import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {usePalette} from '#/lib/hooks/usePalette' @@ -74,6 +73,7 @@ import * as Menu from '#/components/Menu' import * as Prompt from '#/components/Prompt' import {Text} from '#/components/Typography' +import {useActorStatus} from '#/features/liveNow' import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army' import {router} from '../../../routes'