From a0bd8042621e108f47e09dd096cf0d73fe1cee53 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sat, 10 May 2025 00:06:06 +0300 Subject: Live (#8354) --- src/alf/atoms.ts | 3 + src/components/AccountList.tsx | 4 + src/components/Button.tsx | 86 +++++ src/components/Dialog/index.tsx | 16 +- src/components/Dialog/index.web.tsx | 8 +- src/components/ProfileCard.tsx | 7 + src/components/ProfileHoverCard/index.web.tsx | 50 ++- src/components/ProfileHoverCard/types.ts | 2 +- .../EmailDialog/screens/VerificationReminder.tsx | 4 + src/components/forms/DateField/index.android.tsx | 6 +- src/components/forms/DateField/index.tsx | 7 +- src/components/icons/Live.tsx | 5 + src/components/live/EditLiveDialog.tsx | 348 ++++++++++++++++++++ src/components/live/GoLiveDialog.tsx | 352 +++++++++++++++++++++ src/components/live/LiveIndicator.tsx | 53 ++++ src/components/live/LiveStatusDialog.tsx | 212 +++++++++++++ src/components/live/queries.ts | 187 +++++++++++ src/components/live/temp.ts | 41 +++ src/components/live/utils.ts | 37 +++ src/lib/actor-status.ts | 51 +++ src/lib/api/resolve.ts | 14 +- src/lib/constants.ts | 1 + src/lib/strings/url-helpers.ts | 30 ++ src/logger/metrics.ts | 9 + .../Profile/Header/ProfileHeaderStandard.tsx | 6 +- src/screens/Profile/Header/Shell.tsx | 100 ++++-- src/screens/Settings/Settings.tsx | 6 + src/state/cache/profile-shadow.ts | 7 + src/view/com/post-thread/PostThreadItem.tsx | 5 + src/view/com/post/Post.tsx | 15 +- src/view/com/posts/AviFollowButton.tsx | 143 --------- src/view/com/posts/AviFollowButton.web.tsx | 5 - src/view/com/posts/PostFeed.tsx | 32 +- src/view/com/posts/PostFeedItem.tsx | 23 +- src/view/com/profile/ProfileMenu.tsx | 38 +++ src/view/com/util/PostMeta.tsx | 4 + src/view/com/util/UserAvatar.tsx | 88 +++++- src/view/screens/Storybook/Buttons.tsx | 58 ++-- src/view/screens/Storybook/index.tsx | 3 +- src/view/shell/Drawer.tsx | 3 + src/view/shell/bottom-bar/BottomBar.tsx | 24 +- src/view/shell/desktop/LeftNav.tsx | 76 +++-- 42 files changed, 1869 insertions(+), 300 deletions(-) create mode 100644 src/components/icons/Live.tsx create mode 100644 src/components/live/EditLiveDialog.tsx create mode 100644 src/components/live/GoLiveDialog.tsx create mode 100644 src/components/live/LiveIndicator.tsx create mode 100644 src/components/live/LiveStatusDialog.tsx create mode 100644 src/components/live/queries.ts create mode 100644 src/components/live/temp.ts create mode 100644 src/components/live/utils.ts create mode 100644 src/lib/actor-status.ts delete mode 100644 src/view/com/posts/AviFollowButton.tsx delete mode 100644 src/view/com/posts/AviFollowButton.web.tsx (limited to 'src') diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 68aa3cc88..02ad98c5f 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -27,6 +27,9 @@ export const atoms = { relative: { position: 'relative', }, + static: { + position: 'static', + }, sticky: web({ position: 'sticky', }), diff --git a/src/components/AccountList.tsx b/src/components/AccountList.tsx index f2e781ccf..eb770eeca 100644 --- a/src/components/AccountList.tsx +++ b/src/components/AccountList.tsx @@ -4,6 +4,7 @@ import {type AppBskyActorDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useActorStatus} from '#/lib/actor-status' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {useProfilesQuery} from '#/state/queries/profile' @@ -110,6 +111,7 @@ function AccountItem({ const t = useTheme() const {_} = useLingui() const verification = useSimpleVerificationState({profile}) + const {isActive: live} = useActorStatus(profile) const onPress = useCallback(() => { onSelect(account) @@ -141,6 +143,8 @@ function AccountItem({ avatar={profile?.avatar} size={36} type={profile?.associated?.labeler ? 'labeler' : 'user'} + live={live} + hideLiveBadge /> diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 2d6ddc834..42eb64844 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -26,6 +26,7 @@ export type ButtonColor = | 'secondary' | 'secondary_inverted' | 'negative' + | 'negative_secondary' | 'gradient_primary' | 'gradient_sky' | 'gradient_midnight' @@ -336,6 +337,57 @@ export const Button = React.forwardRef( borderWidth: 1, }) + if (!disabled) { + baseStyles.push(a.border, { + borderColor: t.palette.negative_500, + }) + hoverStyles.push(a.border, { + backgroundColor: t.palette.negative_50, + }) + } else { + baseStyles.push(a.border, { + borderColor: t.palette.negative_200, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push(t.atoms.bg) + hoverStyles.push({ + backgroundColor: t.palette.negative_100, + }) + } + } + } else if (color === 'negative_secondary') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({ + backgroundColor: select(t.name, { + light: t.palette.negative_50, + dim: t.palette.negative_100, + dark: t.palette.negative_100, + }), + }) + hoverStyles.push({ + backgroundColor: select(t.name, { + light: t.palette.negative_100, + dim: t.palette.negative_200, + dark: t.palette.negative_200, + }), + }) + } else { + baseStyles.push({ + backgroundColor: select(t.name, { + light: t.palette.negative_100, + dim: t.palette.negative_50, + dark: t.palette.negative_50, + }), + }) + } + } else if (variant === 'outline') { + baseStyles.push(a.border, t.atoms.bg, { + borderWidth: 1, + }) + if (!disabled) { baseStyles.push(a.border, { borderColor: t.palette.negative_500, @@ -425,6 +477,7 @@ export const Button = React.forwardRef( secondary: tokens.gradients.sky, secondary_inverted: tokens.gradients.sky, negative: tokens.gradients.sky, + negative_secondary: tokens.gradients.sky, gradient_primary: tokens.gradients.primary, gradient_sky: tokens.gradients.sky, gradient_midnight: tokens.gradients.midnight, @@ -645,6 +698,39 @@ export function useSharedButtonTextStyles() { baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) } } + } else if (color === 'negative_secondary') { + if (variant === 'solid' || variant === 'gradient') { + if (!disabled) { + baseStyles.push({ + color: select(t.name, { + light: t.palette.negative_500, + dim: t.palette.negative_950, + dark: t.palette.negative_900, + }), + }) + } else { + baseStyles.push({ + color: select(t.name, { + light: t.palette.negative_500, + dim: t.palette.negative_700, + dark: t.palette.negative_700, + }), + opacity: 0.5, + }) + } + } else if (variant === 'outline') { + if (!disabled) { + baseStyles.push({color: t.palette.negative_400}) + } else { + baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push({color: t.palette.negative_400}) + } else { + baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) + } + } } else { if (!disabled) { baseStyles.push({color: t.palette.white}) diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 4c6c5816c..4795385ee 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -307,7 +307,7 @@ export const InnerFlatList = React.forwardRef< ) }) -export function Handle() { +export function Handle({difference = false}: {difference?: boolean}) { const t = useTheme() const {_} = useLingui() const {screenReaderEnabled} = useA11y() @@ -328,9 +328,19 @@ export function Handle() { width: 35, height: 5, alignSelf: 'center', - backgroundColor: t.palette.contrast_975, - opacity: 0.5, }, + difference + ? { + // TODO: mixBlendMode is only available on the new architecture -sfn + // backgroundColor: t.palette.white, + // mixBlendMode: 'difference', + backgroundColor: t.palette.white, + opacity: 0.75, + } + : { + backgroundColor: t.palette.contrast_975, + opacity: 0.5, + }, ]} /> diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index c43e9c5c0..12bd8819b 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -195,13 +195,7 @@ export function Inner({ onDismiss={close} style={{display: 'flex', flexDirection: 'column'}}> {header} - + {children} diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index aa5830eb9..30b26bead 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -8,6 +8,7 @@ import { import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useActorStatus} from '#/lib/actor-status' import {getModerationCauseKey} from '#/lib/moderation' import {type LogEvents} from '#/lib/statsig/statsig' import {sanitizeDisplayName} from '#/lib/strings/display-names' @@ -132,20 +133,25 @@ export function Avatar({ moderationOpts, onPress, disabledPreview, + liveOverride, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts onPress?: () => void disabledPreview?: boolean + liveOverride?: boolean }) { const moderation = moderateProfile(profile, moderationOpts) + const {isActive: live} = useActorStatus(profile) + return disabledPreview ? ( ) : ( ) } diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index 09b587c5e..4f6545a2e 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useCallback} from 'react' import {View} from 'react-native' import { type AppBskyActorDefs, @@ -8,10 +8,13 @@ import { import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom' import {msg, plural} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' +import {useActorStatus} from '#/lib/actor-status' import {isTouchDevice} from '#/lib/browser' import {getModerationCauseKey} from '#/lib/moderation' import {makeProfileLink} from '#/lib/routes/links' +import {type NavigationProp} from '#/lib/routes/types' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {useProfileShadow} from '#/state/cache/profile-shadow' @@ -32,6 +35,7 @@ import { 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' @@ -105,6 +109,8 @@ const HIDE_DELAY = 150 const HIDE_DURATION = 200 export function ProfileHoverCardInner(props: ProfileHoverCardProps) { + const navigation = useNavigation() + const {refs, floatingStyles} = useFloating({ middleware: floatingMiddlewares, }) @@ -330,7 +336,7 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) { onPointerEnter={onPointerEnterCard} onPointerLeave={onPointerLeaveCard}>
- +
@@ -339,7 +345,15 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) { ) } -let Card = ({did, hide}: {did: string; hide: () => void}): React.ReactNode => { +let Card = ({ + did, + hide, + navigation, +}: { + did: string + hide: () => void + navigation: NavigationProp +}): React.ReactNode => { const t = useTheme() const profile = useProfileQuery({did}) @@ -347,24 +361,42 @@ let Card = ({did, hide}: {did: string; hide: () => void}): React.ReactNode => { const data = profile.data + const status = useActorStatus(data) + + const onPressOpenProfile = useCallback(() => { + if (!status.isActive || !data) return + hide() + navigation.push('Profile', { + name: data.handle, + }) + }, [hide, navigation, status, data]) + return ( {data && moderationOpts ? ( - + status.isActive ? ( + + ) : ( + + ) ) : ( - + )} diff --git a/src/components/ProfileHoverCard/types.ts b/src/components/ProfileHoverCard/types.ts index 2fa064383..37087dc95 100644 --- a/src/components/ProfileHoverCard/types.ts +++ b/src/components/ProfileHoverCard/types.ts @@ -1,4 +1,4 @@ -import React from 'react' +import type React from 'react' export type ProfileHoverCardProps = { children: React.ReactElement diff --git a/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx b/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx index 267b784b0..d6c946956 100644 --- a/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx +++ b/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx @@ -45,6 +45,10 @@ export function VerificationReminder({ a.overflow_hidden, a.pt_md, t.atoms.bg_contrast_100, + { + borderTopLeftRadius: a.rounded_md.borderRadius, + borderTopRightRadius: a.rounded_md.borderRadius, + }, ]}> diff --git a/src/components/forms/DateField/index.android.tsx b/src/components/forms/DateField/index.android.tsx index 3be555238..2a89be7d3 100644 --- a/src/components/forms/DateField/index.android.tsx +++ b/src/components/forms/DateField/index.android.tsx @@ -1,9 +1,10 @@ import {useCallback, useImperativeHandle, useState} from 'react' import {Keyboard} from 'react-native' import DatePicker from 'react-native-date-picker' +import {useLingui} from '@lingui/react' import {useTheme} from '#/alf' -import {DateFieldProps} from '#/components/forms/DateField/types' +import {type DateFieldProps} from '#/components/forms/DateField/types' import {toSimpleDateString} from '#/components/forms/DateField/utils' import * as TextField from '#/components/forms/TextField' import {DateFieldButton} from './index.shared' @@ -21,6 +22,7 @@ export function DateField({ accessibilityHint, maximumDate, }: DateFieldProps) { + const {i18n} = useLingui() const t = useTheme() const [open, setOpen] = useState(false) @@ -80,6 +82,8 @@ export function DateField({ onConfirm={onChangeInternal} onCancel={onCancel} mode="date" + locale={i18n.locale} + is24hourSource="locale" testID={`${testID}-datepicker`} aria-label={label} accessibilityLabel={label} diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx index b8ecf2e6f..3683ee9e6 100644 --- a/src/components/forms/DateField/index.tsx +++ b/src/components/forms/DateField/index.tsx @@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {DateFieldProps} from '#/components/forms/DateField/types' +import {type DateFieldProps} from '#/components/forms/DateField/types' import {toSimpleDateString} from '#/components/forms/DateField/utils' import * as TextField from '#/components/forms/TextField' import {DateFieldButton} from './index.shared' @@ -33,7 +33,7 @@ export function DateField({ accessibilityHint, maximumDate, }: DateFieldProps) { - const {_} = useLingui() + const {_, i18n} = useLingui() const t = useTheme() const control = Dialog.useDialogControl() @@ -83,10 +83,11 @@ export function DateField({ + + + + ) +} + +function DialogInner({ + status, + embed, +}: { + status: AppBskyActorDefs.StatusView + embed: AppBskyEmbedExternal.View +}) { + const control = Dialog.useDialogContext() + const {_, i18n} = useLingui() + const t = useTheme() + const agent = useAgent() + const [liveLink, setLiveLink] = useState(embed.external.uri) + const [liveLinkError, setLiveLinkError] = useState('') + const [imageLoadError, setImageLoadError] = useState(false) + 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, + } = useQuery({ + enabled: !!debouncedUrl, + queryKey: ['link-meta', debouncedUrl], + queryFn: async () => { + if (!debouncedUrl) return null + return getLinkMeta(agent, 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(() => { + 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) + )} + + + )} + + {(linkMeta || linkMetaLoading) && ( + + {(!linkMeta || linkMeta.image) && ( + + {linkMeta?.image && ( + setImageLoadError(false)} + onError={() => setImageLoadError(true)} + /> + )} + {linkMeta && imageLoadError && ( + + )} + + )} + + {linkMeta ? ( + <> + + {linkMeta.title || linkMeta.url} + + + + + {toNiceDomain(linkMeta.url)} + + + + ) : ( + <> + + + + )} + + + )} + + + {goLiveError && ( + {cleanError(goLiveError)} + )} + {removeLiveStatusError && ( + + {cleanError(removeLiveStatusError)} + + )} + + + {isDirty ? ( + + ) : ( + + )} + + + + + + ) +} diff --git a/src/components/live/GoLiveDialog.tsx b/src/components/live/GoLiveDialog.tsx new file mode 100644 index 000000000..2fad009fd --- /dev/null +++ b/src/components/live/GoLiveDialog.tsx @@ -0,0 +1,352 @@ +import {useCallback, useState} from 'react' +import {View} from 'react-native' +import {Image} from 'expo-image' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQuery} from '@tanstack/react-query' + +import {getLinkMeta} from '#/lib/link-meta/link-meta' +import {cleanError} from '#/lib/strings/errors' +import {toNiceDomain} from '#/lib/strings/url-helpers' +import {definitelyUrl} from '#/lib/strings/url-helpers' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useAgent} from '#/state/session' +import {useTickEveryMinute} from '#/state/shell' +import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +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 {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX' +import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +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 {useUpsertLiveStatusMutation} from './queries' +import {displayDuration, useDebouncedValue} from './utils' + +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 agent = useAgent() + const [liveLink, setLiveLink] = useState('') + const [liveLinkError, setLiveLinkError] = useState('') + const [imageLoadError, setImageLoadError] = useState(false) + const [duration, setDuration] = useState(60) + const moderationOpts = useModerationOpts() + const tick = useTickEveryMinute() + + const time = useCallback( + (offset: number) => { + tick! + + const date = new Date() + date.setMinutes(date.getMinutes() + offset) + return i18n + .date(date, {hour: 'numeric', minute: '2-digit', hour12: true}) + .toLocaleUpperCase() + .replace(' ', '') + }, + [tick, i18n], + ) + + const onChangeDuration = useCallback((newDuration: string) => { + setDuration(Number(newDuration)) + }, []) + + const liveLinkUrl = definitelyUrl(liveLink) + const debouncedUrl = useDebouncedValue(liveLinkUrl, 500) + const hasLink = !!debouncedUrl + + const { + data: linkMeta, + isSuccess: hasValidLinkMeta, + isLoading: linkMetaLoading, + error: linkMetaError, + } = useQuery({ + enabled: !!debouncedUrl, + queryKey: ['link-meta', debouncedUrl], + queryFn: async () => { + if (!debouncedUrl) return null + return getLinkMeta(agent, debouncedUrl) + }, + }) + + const { + mutate: goLive, + isPending: isGoingLive, + error: goLiveError, + } = useUpsertLiveStatusMutation(duration, linkMeta) + + 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) + )} + + + )} + + {(linkMeta || linkMetaLoading) && ( + + {(!linkMeta || linkMeta.image) && ( + + {linkMeta?.image && ( + setImageLoadError(false)} + onError={() => setImageLoadError(true)} + /> + )} + {linkMeta && imageLoadError && ( + + )} + + )} + + {linkMeta ? ( + <> + + {linkMeta.title || linkMeta.url} + + + + + {toNiceDomain(linkMeta.url)} + + + + ) : ( + <> + + + + )} + + + )} + + + {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/LiveIndicator.tsx b/src/components/live/LiveIndicator.tsx new file mode 100644 index 000000000..c237e8c83 --- /dev/null +++ b/src/components/live/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/components/live/LiveStatusDialog.tsx b/src/components/live/LiveStatusDialog.tsx new file mode 100644 index 000000000..c892dea58 --- /dev/null +++ b/src/components/live/LiveStatusDialog.tsx @@ -0,0 +1,212 @@ +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 {logger} from '#/logger' +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 * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +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, +}: { + control: Dialog.DialogControlProps + profile: bsky.profile.AnyProfileView + status: AppBskyActorDefs.StatusView + embed: AppBskyEmbedExternal.View +}) { + const navigation = useNavigation() + return ( + + + + + ) +} + +function DialogInner({ + profile, + embed, + navigation, +}: { + profile: bsky.profile.AnyProfileView + embed: AppBskyEmbedExternal.View + navigation: NavigationProp +}) { + 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({ + profile, + embed, + padding = 'xl', + onPressOpenProfile, +}: { + profile: bsky.profile.AnyProfileView + embed: AppBskyEmbedExternal.View + padding?: 'lg' | 'xl' + onPressOpenProfile: () => void +}) { + const {_} = useLingui() + const t = useTheme() + const queryClient = useQueryClient() + const openLink = useOpenLink() + const moderationOpts = useModerationOpts() + + 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 testing + + + + ) +} diff --git a/src/components/live/queries.ts b/src/components/live/queries.ts new file mode 100644 index 000000000..1958ab49d --- /dev/null +++ b/src/components/live/queries.ts @@ -0,0 +1,187 @@ +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, useQueryClient} from '@tanstack/react-query' + +import {uploadBlob} from '#/lib/api' +import {imageToThumb} from '#/lib/api/resolve' +import {type LinkMeta} from '#/lib/link-meta/link-meta' +import {logger} from '#/logger' +import {updateProfileShadow} from '#/state/cache/profile-shadow' +import {useAgent, useSession} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' +import {useDialogContext} from '#/components/Dialog' + +export function useUpsertLiveStatusMutation( + duration: number, + linkMeta: LinkMeta | null | undefined, + createdAt?: string, +) { + 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) { + 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) => { + logger.error(`Failed to upsert live status`, { + url: linkMeta?.url, + image: linkMeta?.image, + safeMessage: e, + }) + }, + onSuccess: ({record, image}) => { + if (createdAt) { + logger.metric('live:edit', {duration: record.durationMinutes}) + } else { + logger.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 {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) => { + logger.error(`Failed to remove live status`, { + safeMessage: e, + }) + }, + onSuccess: () => { + logger.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/temp.ts b/src/components/live/temp.ts new file mode 100644 index 000000000..fb26b8c06 --- /dev/null +++ b/src/components/live/temp.ts @@ -0,0 +1,41 @@ +import {type AppBskyActorDefs, AppBskyEmbedExternal} from '@atproto/api' + +import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' +import type * as bsky from '#/types/bsky' + +export const LIVE_DIDS: Record = { + 'did:plc:7sfnardo5xxznxc6esxc5ooe': true, // nba.com + 'did:plc:gx6fyi3jcfxd7ammq2t7mzp2': true, // rtgame.bsky.social +} + +export const LIVE_SOURCES: Record = { + 'nba.com': true, + 'twitch.tv': true, +} + +// TEMP: dumb gating +export function temp__canBeLive(profile: bsky.profile.AnyProfileView) { + if (__DEV__) + return !!DISCOVER_DEBUG_DIDS[profile.did] || !!LIVE_DIDS[profile.did] + return !!LIVE_DIDS[profile.did] +} + +export function temp__canGoLive(profile: bsky.profile.AnyProfileView) { + if (__DEV__) return true + return !!LIVE_DIDS[profile.did] +} + +// status must have a embed, and the embed must be an approved host for the status to be valid +export function temp__isStatusValid(status: AppBskyActorDefs.StatusView) { + if (status.status !== 'app.bsky.actor.status#live') return false + try { + if (AppBskyEmbedExternal.isView(status.embed)) { + const url = new URL(status.embed.external.uri) + return !!LIVE_SOURCES[url.hostname] + } else { + return false + } + } catch { + return false + } +} diff --git a/src/components/live/utils.ts b/src/components/live/utils.ts new file mode 100644 index 000000000..6b4267cb0 --- /dev/null +++ b/src/components/live/utils.ts @@ -0,0 +1,37 @@ +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 +} diff --git a/src/lib/actor-status.ts b/src/lib/actor-status.ts new file mode 100644 index 000000000..30921a88a --- /dev/null +++ b/src/lib/actor-status.ts @@ -0,0 +1,51 @@ +import {useMemo} from 'react' +import { + type $Typed, + type AppBskyActorDefs, + type AppBskyEmbedExternal, +} from '@atproto/api' +import {isAfter, parseISO} from 'date-fns' + +import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' +import {useTickEveryMinute} from '#/state/shell' +import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp' +import type * as bsky from '#/types/bsky' + +export function useActorStatus(actor?: bsky.profile.AnyProfileView) { + const shadowed = useMaybeProfileShadow(actor) + const tick = useTickEveryMinute() + return useMemo(() => { + tick! // revalidate every minute + + if ( + shadowed && + temp__canBeLive(shadowed) && + 'status' in shadowed && + shadowed.status && + temp__isStatusValid(shadowed.status) && + isStatusStillActive(shadowed.status.expiresAt) + ) { + return { + 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 + } else { + return { + status: '', + isActive: false, + record: {}, + } satisfies AppBskyActorDefs.StatusView + } + }, [shadowed, tick]) +} + +export function isStatusStillActive(timeStr: string | undefined) { + if (!timeStr) return false + const now = new Date() + const expiry = parseISO(timeStr) + + return isAfter(expiry, now) +} diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts index 371062350..93d16ff0c 100644 --- a/src/lib/api/resolve.ts +++ b/src/lib/api/resolve.ts @@ -1,10 +1,10 @@ import { - AppBskyFeedDefs, - AppBskyGraphDefs, - ComAtprotoRepoStrongRef, + type AppBskyFeedDefs, + type AppBskyGraphDefs, + type ComAtprotoRepoStrongRef, } from '@atproto/api' import {AtUri} from '@atproto/api' -import {BskyAgent} from '@atproto/api' +import {type BskyAgent} from '@atproto/api' import {POST_IMG_MAX} from '#/lib/constants' import {getLinkMeta} from '#/lib/link-meta/link-meta' @@ -22,9 +22,9 @@ import { isBskyStartUrl, isShortLink, } from '#/lib/strings/url-helpers' -import {ComposerImage} from '#/state/gallery' +import {type ComposerImage} from '#/state/gallery' import {createComposerImage} from '#/state/gallery' -import {Gif} from '#/state/queries/tenor' +import {type Gif} from '#/state/queries/tenor' import {createGIFDescription} from '../gif-alt-text' import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers' @@ -213,7 +213,7 @@ async function resolveExternal( } } -async function imageToThumb( +export async function imageToThumb( imageUri: string, ): Promise { try { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index bb98f9fc8..dca03647a 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -31,6 +31,7 @@ export const DISCOVER_DEBUG_DIDS: Record = { 'did:plc:3jpt2mvvsumj2r7eqk4gzzjz': true, // esb.lol 'did:plc:vjug55kidv6sye7ykr5faxxn': true, // emilyliu.me 'did:plc:tgqseeot47ymot4zro244fj3': true, // iwsmith.bsky.social + 'did:plc:2dzyut5lxna5ljiaasgeuffz': true, // mrnuma.bsky.social } const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new` diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 20c3fabbc..ad194714a 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -372,3 +372,33 @@ export function getServiceAuthAudFromUrl(url: string | URL): string | null { } return `did:web:${hostname}` } + +// passes URL.parse, and has a TLD etc +export function definitelyUrl(maybeUrl: string) { + try { + if (maybeUrl.endsWith('.')) return null + + // Prepend 'https://' if the input doesn't start with a protocol + if (!maybeUrl.startsWith('https://') && !maybeUrl.startsWith('http://')) { + maybeUrl = 'https://' + maybeUrl + } + + const url = new URL(maybeUrl) + + // Extract the hostname and split it into labels + const hostname = url.hostname + const labels = hostname.split('.') + + // Ensure there are at least two labels (e.g., 'example' and 'com') + if (labels.length < 2) return null + + const tld = labels[labels.length - 1] + + // Check that the TLD is at least two characters long and contains only letters + if (!/^[a-z]{2,}$/i.test(tld)) return null + + return url.toString() + } catch { + return null + } +} diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index 42b0d6ef3..665633d7c 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -383,4 +383,13 @@ export type MetricEvents = { } 'verification:settings:hideBadges': {} 'verification:settings:unHideBadges': {} + + 'live:create': {duration: number} + 'live:edit': {} + 'live:remove': {} + 'live:card:open': {subject: string; from: 'post' | 'profile'} + 'live:card:watch': {subject: string} + 'live:card:openProfile': {subject: string} + 'live:view:profile': {subject: string} + 'live:view:post': {subject: string; feed?: string} } diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 2dff101e6..1639abaf0 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -9,6 +9,7 @@ import { import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useActorStatus} from '#/lib/actor-status' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' @@ -138,6 +139,8 @@ let ProfileHeaderStandard = ({ [currentAccount, profile], ) + const {isActive: live} = useActorStatus(profile) + return ( - + () const {top: topInset} = useSafeAreaInsets() + const playHaptic = useHaptics() + const liveStatusControl = useDialogControl() const aviRef = useHandleRef() @@ -79,24 +92,46 @@ let ProfileHeaderShell = ({ [openLightbox], ) - const onPressAvi = React.useCallback(() => { - const modui = moderation.ui('avatar') - const avatar = profile.avatar - if (avatar && !(modui.blur && modui.noOverride)) { - const aviHandle = aviRef.current - runOnUI(() => { - 'worklet' - const rect = measureHandle(aviHandle) - runOnJS(_openLightbox)(avatar, rect) - })() - } - }, [profile, moderation, _openLightbox, aviRef]) - const isMe = React.useMemo( () => currentAccount?.did === profile.did, [currentAccount, profile], ) + const live = useActorStatus(profile) + + useEffect(() => { + if (live.isActive) { + logger.metric('live:view:profile', {subject: profile.did}) + } + }, [live.isActive, profile.did]) + + const onPressAvi = React.useCallback(() => { + if (live.isActive) { + playHaptic('Light') + logger.metric('live:card:open', {subject: profile.did, from: 'profile'}) + liveStatusControl.open() + } else { + const modui = moderation.ui('avatar') + const avatar = profile.avatar + if (avatar && !(modui.blur && modui.noOverride)) { + const aviHandle = aviRef.current + runOnUI(() => { + 'worklet' + const rect = measureHandle(aviHandle) + runOnJS(_openLightbox)(avatar, rect) + })() + } + } + }, [ + profile, + moderation, + _openLightbox, + aviRef, + liveStatusControl, + live, + playHaptic, + ]) + return ( + {live.isActive && } + + {live.isActive && + (isMe ? ( + + ) : ( + + ))} ) } @@ -219,8 +277,6 @@ const styles = StyleSheet.create({ avi: { width: 94, height: 94, - borderRadius: 47, - borderWidth: 2, }, aviLabeler: { borderRadius: 10, diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index 76eb48203..9f36c27ac 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {type NativeStackScreenProps} from '@react-navigation/native-stack' +import {useActorStatus} from '#/lib/actor-status' import {IS_INTERNAL} from '#/lib/app-info' import {HELP_DESK_URL} from '#/lib/constants' import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' @@ -287,6 +288,7 @@ function ProfilePreview({ const verificationState = useFullVerificationState({ profile: shadow, }) + const {isActive: live} = useActorStatus(profile) if (!moderationOpts) return null @@ -303,6 +305,7 @@ function ProfilePreview({ avatar={shadow.avatar} moderation={moderation.ui('avatar')} type={shadow.associated?.labeler ? 'labeler' : 'user'} + live={live} /> { if (pendingDid) return @@ -485,6 +489,8 @@ function AccountRow({ avatar={profile.avatar} moderation={moderateProfile(profile, moderationOpts).ui('avatar')} type={profile.associated?.labeler ? 'labeler' : 'user'} + live={live} + hideLiveBadge /> ) : ( diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index 9c23e4550..a1212d8a2 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -31,6 +31,7 @@ export interface ProfileShadow { muted: boolean | undefined blockingUri: string | undefined verification: AppBskyActorDefs.VerificationState + status: AppBskyActorDefs.StatusView | undefined } const shadows: WeakMap< @@ -138,6 +139,12 @@ function mergeShadow( }, verification: 'verification' in shadow ? shadow.verification : profile.verification, + status: + 'status' in shadow + ? shadow.status + : 'status' in profile + ? profile.status + : undefined, }) } diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 10c3e6b4d..3925ce9bd 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -16,6 +16,7 @@ import { import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useActorStatus} from '#/lib/actor-status' import {MAX_POST_LINES} from '#/lib/constants' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenLink} from '#/lib/hooks/useOpenLink' @@ -287,6 +288,8 @@ let PostThreadItemLoaded = ({ setLimitLines(false) }, [setLimitLines]) + const {isActive: live} = useActorStatus(post.author) + if (!record) { return } @@ -330,6 +333,7 @@ let PostThreadItemLoaded = ({ profile={post.author} moderation={moderation.ui('avatar')} type={post.author.associated?.labeler ? 'labeler' : 'user'} + live={live} /> @@ -575,6 +579,7 @@ let PostThreadItemLoaded = ({ profile={post.author} moderation={moderation.ui('avatar')} type={post.author.associated?.labeler ? 'labeler' : 'user'} + live={live} /> {showChildReplyLine && ( diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index c6cf254f3..03463f977 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -27,7 +27,6 @@ import { import {useModerationOpts} from '#/state/preferences/moderation-opts' import {precacheProfile} from '#/state/queries/profile' import {useSession} from '#/state/session' -import {AviFollowButton} from '#/view/com/posts/AviFollowButton' import {atoms as a} from '#/alf' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {RichText} from '#/components/RichText' @@ -174,14 +173,12 @@ function PostInner({ {showReplyLine && } - - - + () - - const name = sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - ) - const isFollowing = - profile.viewer?.following || profile.did === currentAccount?.did - - function onPress() { - follow() - Toast.show(_(msg`Following ${name}`)) - } - - const items: DropdownItem[] = [ - { - label: _(msg`View profile`), - onPress: () => { - navigation.navigate('Profile', {name: profile.did}) - }, - icon: { - ios: { - name: 'arrow.up.right.square', - }, - android: '', - web: ['far', 'arrow-up-right-from-square'], - }, - }, - { - label: _(msg`Follow ${name}`), - onPress: onPress, - icon: { - ios: { - name: 'person.badge.plus', - }, - android: '', - web: ['far', 'user-plus'], - }, - }, - ] - - return hasSession ? ( - - {children} - - {!isFollowing && ( - - )} - - ) : ( - children - ) -} diff --git a/src/view/com/posts/AviFollowButton.web.tsx b/src/view/com/posts/AviFollowButton.web.tsx deleted file mode 100644 index 90b2ddeec..000000000 --- a/src/view/com/posts/AviFollowButton.web.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react' - -export function AviFollowButton({children}: {children: React.ReactNode}) { - return children -} diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 181b35026..b4c2b2710 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -1,4 +1,4 @@ -import React, {memo, useCallback} from 'react' +import React, {memo, useCallback, useRef} from 'react' import { ActivityIndicator, AppState, @@ -19,6 +19,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {isStatusStillActive} from '#/lib/actor-status' import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {logEvent} from '#/lib/statsig/statsig' @@ -52,6 +53,7 @@ import { } from '#/components/feeds/PostFeedVideoGridRow' import {TrendingInterstitial} from '#/components/interstitials/Trending' import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' +import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp' import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' import {FeedShutdownMsg} from './FeedShutdownMsg' import {PostFeedErrorMessage} from './PostFeedErrorMessage' @@ -775,6 +777,31 @@ let PostFeed = ({ ) }, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset]) + const seenActorWithStatusRef = useRef>(new Set()) + const onItemSeen = useCallback( + (item: FeedRow) => { + feedFeedback.onItemSeen(item) + if (item.type === 'sliceItem') { + const actor = item.slice.items[item.indexInSlice].post.author + if ( + actor.status && + temp__canBeLive(actor) && + temp__isStatusValid(actor.status) && + isStatusStillActive(actor.status.expiresAt) + ) { + if (!seenActorWithStatusRef.current.has(actor.did)) { + seenActorWithStatusRef.current.add(actor.did) + logger.metric('live:view:post', { + subject: actor.did, + feed, + }) + } + } + } + }, + [feedFeedback, feed], + ) + return ( ) diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index 123a8b0c2..ceb653b9c 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -17,6 +17,7 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {useActorStatus} from '#/lib/actor-status' import {isReasonFeedSource, type ReasonFeedSource} from '#/lib/api/feed/types' import {MAX_POST_LINES} from '#/lib/constants' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' @@ -53,7 +54,6 @@ import {RichText} from '#/components/RichText' import {SubtleWebHover} from '#/components/SubtleWebHover' import * as bsky from '#/types/bsky' import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link' -import {AviFollowButton} from './AviFollowButton' interface FeedItemProps { record: AppBskyFeedPost.Record @@ -251,6 +251,8 @@ let FeedItemInner = ({ ? rootPost.threadgate.record : undefined + const {isActive: live} = useActorStatus(post.author) + return ( - - - + {isThreadParent && ( diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 43ec44834..1c2a7d62d 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react' 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' @@ -23,12 +24,14 @@ import {useSession} from '#/state/session' import {EventStopper} from '#/view/com/util/EventStopper' import * as Toast from '#/view/com/util/Toast' import {Button, ButtonIcon} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck' import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX' import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' +import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2' @@ -38,6 +41,9 @@ import { } from '#/components/icons/Person' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' +import {EditLiveDialog} from '#/components/live/EditLiveDialog' +import {GoLiveDialog} from '#/components/live/GoLiveDialog' +import {temp__canGoLive} from '#/components/live/temp' import * as Menu from '#/components/Menu' import { ReportDialog, @@ -77,6 +83,7 @@ let ProfileMenu = ({ const blockPromptControl = Prompt.usePromptControl() const loggedOutWarningPromptControl = Prompt.usePromptControl() + const goLiveDialogControl = useDialogControl() const showLoggedOutWarning = React.useMemo(() => { return ( @@ -201,6 +208,8 @@ let ProfileMenu = ({ return v.issuer === currentAccount?.did }) ?? [] + const status = useActorStatus(profile) + return ( @@ -290,6 +299,25 @@ let ProfileMenu = ({ + {isSelf && temp__canGoLive(profile) && ( + + + {status.isActive ? ( + Edit live status + ) : ( + Go live + )} + + + + )} {verification.viewer.role === 'verifier' && !verification.profile.isViewer && (verification.viewer.hasIssuedVerification ? ( @@ -456,6 +484,16 @@ let ProfileMenu = ({ profile={profile} verifications={currentAccountVerifications} /> + + {status.isActive ? ( + + ) : ( + + )} ) } diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index fd8e3a38b..62ba32c9b 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -6,6 +6,7 @@ import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import type React from 'react' +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' @@ -55,6 +56,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { const timestampLabel = niceDate(i18n, opts.timestamp) const verification = useSimpleVerificationState({profile: author}) + const {isActive: live} = useActorStatus(author) return ( { profile={author} moderation={opts.moderation?.ui('avatar')} type={author.associated?.labeler ? 'labeler' : 'user'} + live={live} + hideLiveBadge /> )} diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 326a2fff8..b3bf144f7 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -14,6 +14,9 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {useActorStatus} from '#/lib/actor-status' +import {isTouchDevice} from '#/lib/browser' +import {useHaptics} from '#/lib/haptics' import { useCameraPermission, usePhotoLibraryPermission, @@ -22,6 +25,8 @@ import {compressIfNeeded} from '#/lib/media/manip' import {openCamera, openCropper, openPicker} from '#/lib/media/picker' import {type PickerImage} from '#/lib/media/picker.shared' import {makeProfileLink} from '#/lib/routes/links' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {isAndroid, isNative, isWeb} from '#/platform/detection' import { @@ -33,6 +38,7 @@ import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' import {HighPriorityImage} from '#/view/com/util/images/Image' import {atoms as a, tokens, useTheme} from '#/alf' +import {Button} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import { @@ -42,6 +48,8 @@ import { 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' @@ -54,6 +62,8 @@ interface BaseUserAvatarProps { shape?: 'circle' | 'square' size: number avatar?: string | null + live?: boolean + hideLiveBadge?: boolean } interface UserAvatarProps extends BaseUserAvatarProps { @@ -196,27 +206,38 @@ let UserAvatar = ({ usePlainRNImage = false, onLoad, style, + live, + hideLiveBadge, }: UserAvatarProps): React.ReactNode => { const t = useTheme() - const backgroundColor = t.palette.contrast_25 const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') const aviStyle = useMemo(() => { + let borderRadius if (finalShape === 'square') { - return { - width: size, - height: size, - borderRadius: size > 32 ? 8 : 3, - backgroundColor, - } + borderRadius = size > 32 ? 8 : 3 + } else { + borderRadius = Math.floor(size / 2) } + return { width: size, height: size, - borderRadius: Math.floor(size / 2), - backgroundColor, + borderRadius, + backgroundColor: t.palette.contrast_25, } - }, [finalShape, size, backgroundColor]) + }, [finalShape, size, t]) + + const borderStyle = useMemo(() => { + return [ + {borderRadius: aviStyle.borderRadius}, + live && { + borderColor: t.palette.negative_500, + borderWidth: size > 16 ? 2 : 1, + opacity: 1, + }, + ] + }, [aviStyle.borderRadius, live, t, size]) const alert = useMemo(() => { if (!moderation?.alert) { @@ -277,12 +298,19 @@ let UserAvatar = ({ onLoad={onLoad} /> )} - + + {live && size > 16 && !hideLiveBadge && ( + 32 ? 'small' : 'tiny'} /> + )} {alert} ) : ( + + {live && size > 16 && !hideLiveBadge && ( + 32 ? 'small' : 'tiny'} /> + )} {alert} ) @@ -486,21 +514,32 @@ let PreviewableUserAvatar = ({ disableHoverCard, disableNavigation, onBeforePress, + live, ...rest }: PreviewableUserAvatarProps): React.ReactNode => { const {_} = useLingui() const queryClient = useQueryClient() + const status = useActorStatus(profile) + const liveControl = useDialogControl() + const playHaptic = useHaptics() - const onPress = React.useCallback(() => { + const onPress = useCallback(() => { onBeforePress?.() unstableCacheProfileView(queryClient, profile) }, [profile, queryClient, onBeforePress]) + const onOpenLiveStatus = useCallback(() => { + playHaptic('Light') + logger.metric('live:card:open', {subject: profile.did, from: 'post'}) + liveControl.open() + }, [liveControl, playHaptic, profile.did]) + const avatarEl = ( ) @@ -509,9 +548,32 @@ let PreviewableUserAvatar = ({ {disableNavigation ? ( avatarEl + ) : status.isActive && (isNative || isTouchDevice) ? ( + <> + + + ) : ( Buttons - {['primary', 'secondary', 'secondary_inverted', 'negative'].map( - color => ( - - {['solid', 'outline', 'ghost'].map(variant => ( - - - - - ))} - - ), - )} + {[ + 'primary', + 'secondary', + 'secondary_inverted', + 'negative', + 'negative_secondary', + ].map(color => ( + + {['solid', 'outline', 'ghost'].map(variant => ( + + + + + ))} + + ))} diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx index 0146bc3c6..a6c2ecdde 100644 --- a/src/view/screens/Storybook/index.tsx +++ b/src/view/screens/Storybook/index.tsx @@ -2,7 +2,7 @@ import React from 'react' import {View} from 'react-native' import {useNavigation} from '@react-navigation/native' -import {NavigationProp} from '#/lib/routes/types' +import {type NavigationProp} from '#/lib/routes/types' import {useSetThemePrefs} from '#/state/shell' import {ListContained} from '#/view/screens/Storybook/ListContained' import {atoms as a, ThemeProvider} from '#/alf' @@ -115,7 +115,6 @@ function StorybookInner() { - diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index d51db3960..c4624e8e1 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -5,6 +5,7 @@ import {msg, Plural, plural, Trans} from '@lingui/macro' 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' @@ -67,6 +68,7 @@ let DrawerProfileCard = ({ const t = useTheme() const {data: profile} = useProfileQuery({did: account.did}) const verification = useSimpleVerificationState({profile}) + const {isActive: live} = useActorStatus(profile) return ( diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index df6a045dc..92be6c67e 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -7,6 +7,7 @@ import {useLingui} from '@lingui/react' 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' @@ -127,6 +128,7 @@ export function BottomBar({navigation}: BottomTabBarProps) { }, [accountSwitchControl, playHaptic]) const [demoMode] = useDemoMode() + const {isActive: live} = useActorStatus(profile) return ( <> @@ -260,25 +262,39 @@ export function BottomBar({navigation}: BottomTabBarProps) { pal.text, styles.profileIcon, styles.onProfile, - {borderColor: pal.text.color}, + { + borderColor: pal.text.color, + borderWidth: live ? 0 : 1, + }, ]}> ) : ( + style={[ + styles.ctrlIcon, + pal.text, + styles.profileIcon, + { + borderWidth: live ? 0 : 1, + }, + ]}> )} diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 7d34a3d14..f6c852ca1 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -9,6 +9,7 @@ import { 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' @@ -100,6 +101,8 @@ function ProfileCard() { profile: profiles?.find(p => p.did === account.did), })) + const {isActive: live} = useActorStatus(profile) + return ( {!isLoading && profile ? ( @@ -142,6 +145,7 @@ function ProfileCard() { avatar={profile.avatar} size={size} type={profile?.associated?.labeler ? 'labeler' : 'user'} + live={live} /> {!leftNavMinimal && ( @@ -226,7 +230,6 @@ function SwitchMenuItems({ signOutPromptControl: DialogControlProps }) { const {_} = useLingui() - const {onPressSwitchAccount, pendingDid} = useAccountSwitcher() const {setShowLoggedOut} = useLoggedOutViewControls() const closeEverything = useCloseAllActiveElements() @@ -243,35 +246,11 @@ function SwitchMenuItems({ Switch account {accounts.map(other => ( - - onPressSwitchAccount(other.account, 'SwitchAccount') - }> - - - - - {sanitizeHandle( - other.profile?.handle ?? other.account.handle, - '@', - )} - - + account={other.account} + profile={other.profile} + /> ))} @@ -295,6 +274,45 @@ function SwitchMenuItems({ ) } +function SwitchMenuItem({ + account, + profile, +}: { + account: SessionAccount + profile: AppBskyActorDefs.ProfileViewDetailed | undefined +}) { + const {_} = useLingui() + const {onPressSwitchAccount, pendingDid} = useAccountSwitcher() + const {isActive: live} = useActorStatus(profile) + + return ( + onPressSwitchAccount(account, 'SwitchAccount')}> + + + + + {sanitizeHandle(profile?.handle ?? account.handle, '@')} + + + ) +} + interface NavItemProps { count?: string hasNew?: boolean -- cgit 1.4.1