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

Live (#8354)

authored by samuel.fm and committed by

GitHub a0bd8042 2e80fa3d

+2021 -451
+1
assets/icons/live_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M2 12a9.97 9.97 0 0 1 2.929-7.07l.076-.068a1 1 0 0 1 1.407 1.406l-.07.076A7.97 7.97 0 0 0 4 12a7.97 7.97 0 0 0 2.078 5.38l.265.277.07.076a1 1 0 0 1-1.408 1.407l-.076-.07-.331-.346A9.97 9.97 0 0 1 2 12Zm18 0a7.97 7.97 0 0 0-2.078-5.379l-.265-.278-.07-.076a1 1 0 0 1 1.408-1.406l.076.068.331.347A9.97 9.97 0 0 1 22 12a9.97 9.97 0 0 1-2.929 7.07 1 1 0 1 1-1.414-1.413A7.97 7.97 0 0 0 20 12ZM6 12c0-1.656.673-3.158 1.758-4.243a1 1 0 0 1 1.414 1.414A4 4 0 0 0 8 12.001a3.98 3.98 0 0 0 1.04 2.689l.132.138.068.077a1 1 0 0 1-1.407 1.406l-.075-.069-.2-.208A5.98 5.98 0 0 1 6 12Zm10 0a3.98 3.98 0 0 0-1.04-2.69l-.132-.139-.068-.075a1 1 0 0 1 1.407-1.407l.075.068.2.208A5.98 5.98 0 0 1 18 12a6 6 0 0 1-1.758 4.243 1 1 0 0 1-1.414-1.415A4 4 0 0 0 16 12Zm-6 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Z"/></svg>
+2 -2
package.json
··· 69 69 "icons:optimize": "svgo -f ./assets/icons" 70 70 }, 71 71 "dependencies": { 72 - "@atproto/api": "^0.15.5", 72 + "@atproto/api": "^0.15.6", 73 73 "@bitdrift/react-native": "^0.6.8", 74 74 "@braintree/sanitize-url": "^6.0.2", 75 75 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", ··· 219 219 "zod": "^3.20.2" 220 220 }, 221 221 "devDependencies": { 222 - "@atproto/dev-env": "^0.3.128", 222 + "@atproto/dev-env": "^0.3.129", 223 223 "@babel/core": "^7.26.0", 224 224 "@babel/preset-env": "^7.26.0", 225 225 "@babel/runtime": "^7.26.0",
+3
src/alf/atoms.ts
··· 27 27 relative: { 28 28 position: 'relative', 29 29 }, 30 + static: { 31 + position: 'static', 32 + }, 30 33 sticky: web({ 31 34 position: 'sticky', 32 35 }),
+4
src/components/AccountList.tsx
··· 4 4 import {msg, Trans} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 7 + import {useActorStatus} from '#/lib/actor-status' 7 8 import {sanitizeDisplayName} from '#/lib/strings/display-names' 8 9 import {sanitizeHandle} from '#/lib/strings/handles' 9 10 import {useProfilesQuery} from '#/state/queries/profile' ··· 110 111 const t = useTheme() 111 112 const {_} = useLingui() 112 113 const verification = useSimpleVerificationState({profile}) 114 + const {isActive: live} = useActorStatus(profile) 113 115 114 116 const onPress = useCallback(() => { 115 117 onSelect(account) ··· 141 143 avatar={profile?.avatar} 142 144 size={36} 143 145 type={profile?.associated?.labeler ? 'labeler' : 'user'} 146 + live={live} 147 + hideLiveBadge 144 148 /> 145 149 146 150 <View style={[a.flex_1, a.gap_2xs, a.pr_2xl]}>
+86
src/components/Button.tsx
··· 26 26 | 'secondary' 27 27 | 'secondary_inverted' 28 28 | 'negative' 29 + | 'negative_secondary' 29 30 | 'gradient_primary' 30 31 | 'gradient_sky' 31 32 | 'gradient_midnight' ··· 356 357 }) 357 358 } 358 359 } 360 + } else if (color === 'negative_secondary') { 361 + if (variant === 'solid') { 362 + if (!disabled) { 363 + baseStyles.push({ 364 + backgroundColor: select(t.name, { 365 + light: t.palette.negative_50, 366 + dim: t.palette.negative_100, 367 + dark: t.palette.negative_100, 368 + }), 369 + }) 370 + hoverStyles.push({ 371 + backgroundColor: select(t.name, { 372 + light: t.palette.negative_100, 373 + dim: t.palette.negative_200, 374 + dark: t.palette.negative_200, 375 + }), 376 + }) 377 + } else { 378 + baseStyles.push({ 379 + backgroundColor: select(t.name, { 380 + light: t.palette.negative_100, 381 + dim: t.palette.negative_50, 382 + dark: t.palette.negative_50, 383 + }), 384 + }) 385 + } 386 + } else if (variant === 'outline') { 387 + baseStyles.push(a.border, t.atoms.bg, { 388 + borderWidth: 1, 389 + }) 390 + 391 + if (!disabled) { 392 + baseStyles.push(a.border, { 393 + borderColor: t.palette.negative_500, 394 + }) 395 + hoverStyles.push(a.border, { 396 + backgroundColor: t.palette.negative_50, 397 + }) 398 + } else { 399 + baseStyles.push(a.border, { 400 + borderColor: t.palette.negative_200, 401 + }) 402 + } 403 + } else if (variant === 'ghost') { 404 + if (!disabled) { 405 + baseStyles.push(t.atoms.bg) 406 + hoverStyles.push({ 407 + backgroundColor: t.palette.negative_100, 408 + }) 409 + } 410 + } 359 411 } 360 412 361 413 if (shape === 'default') { ··· 425 477 secondary: tokens.gradients.sky, 426 478 secondary_inverted: tokens.gradients.sky, 427 479 negative: tokens.gradients.sky, 480 + negative_secondary: tokens.gradients.sky, 428 481 gradient_primary: tokens.gradients.primary, 429 482 gradient_sky: tokens.gradients.sky, 430 483 gradient_midnight: tokens.gradients.midnight, ··· 631 684 baseStyles.push({color: t.palette.white}) 632 685 } else { 633 686 baseStyles.push({color: t.palette.white, opacity: 0.5}) 687 + } 688 + } else if (variant === 'outline') { 689 + if (!disabled) { 690 + baseStyles.push({color: t.palette.negative_400}) 691 + } else { 692 + baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) 693 + } 694 + } else if (variant === 'ghost') { 695 + if (!disabled) { 696 + baseStyles.push({color: t.palette.negative_400}) 697 + } else { 698 + baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) 699 + } 700 + } 701 + } else if (color === 'negative_secondary') { 702 + if (variant === 'solid' || variant === 'gradient') { 703 + if (!disabled) { 704 + baseStyles.push({ 705 + color: select(t.name, { 706 + light: t.palette.negative_500, 707 + dim: t.palette.negative_950, 708 + dark: t.palette.negative_900, 709 + }), 710 + }) 711 + } else { 712 + baseStyles.push({ 713 + color: select(t.name, { 714 + light: t.palette.negative_500, 715 + dim: t.palette.negative_700, 716 + dark: t.palette.negative_700, 717 + }), 718 + opacity: 0.5, 719 + }) 634 720 } 635 721 } else if (variant === 'outline') { 636 722 if (!disabled) {
+13 -3
src/components/Dialog/index.tsx
··· 307 307 ) 308 308 }) 309 309 310 - export function Handle() { 310 + export function Handle({difference = false}: {difference?: boolean}) { 311 311 const t = useTheme() 312 312 const {_} = useLingui() 313 313 const {screenReaderEnabled} = useA11y() ··· 328 328 width: 35, 329 329 height: 5, 330 330 alignSelf: 'center', 331 - backgroundColor: t.palette.contrast_975, 332 - opacity: 0.5, 333 331 }, 332 + difference 333 + ? { 334 + // TODO: mixBlendMode is only available on the new architecture -sfn 335 + // backgroundColor: t.palette.white, 336 + // mixBlendMode: 'difference', 337 + backgroundColor: t.palette.white, 338 + opacity: 0.75, 339 + } 340 + : { 341 + backgroundColor: t.palette.contrast_975, 342 + opacity: 0.5, 343 + }, 334 344 ]} 335 345 /> 336 346 </Pressable>
+1 -7
src/components/Dialog/index.web.tsx
··· 195 195 onDismiss={close} 196 196 style={{display: 'flex', flexDirection: 'column'}}> 197 197 {header} 198 - <View 199 - style={[ 200 - gtMobile ? a.p_2xl : a.p_xl, 201 - a.overflow_hidden, 202 - a.rounded_md, 203 - contentContainerStyle, 204 - ]}> 198 + <View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}> 205 199 {children} 206 200 </View> 207 201 </DismissableLayer.DismissableLayer>
+7
src/components/ProfileCard.tsx
··· 8 8 import {msg} from '@lingui/macro' 9 9 import {useLingui} from '@lingui/react' 10 10 11 + import {useActorStatus} from '#/lib/actor-status' 11 12 import {getModerationCauseKey} from '#/lib/moderation' 12 13 import {type LogEvents} from '#/lib/statsig/statsig' 13 14 import {sanitizeDisplayName} from '#/lib/strings/display-names' ··· 132 133 moderationOpts, 133 134 onPress, 134 135 disabledPreview, 136 + liveOverride, 135 137 }: { 136 138 profile: bsky.profile.AnyProfileView 137 139 moderationOpts: ModerationOpts 138 140 onPress?: () => void 139 141 disabledPreview?: boolean 142 + liveOverride?: boolean 140 143 }) { 141 144 const moderation = moderateProfile(profile, moderationOpts) 142 145 146 + const {isActive: live} = useActorStatus(profile) 147 + 143 148 return disabledPreview ? ( 144 149 <UserAvatar 145 150 size={40} 146 151 avatar={profile.avatar} 147 152 type={profile.associated?.labeler ? 'labeler' : 'user'} 148 153 moderation={moderation.ui('avatar')} 154 + live={liveOverride ?? live} 149 155 /> 150 156 ) : ( 151 157 <PreviewableUserAvatar ··· 153 159 profile={profile} 154 160 moderation={moderation.ui('avatar')} 155 161 onBeforePress={onPress} 162 + live={liveOverride ?? live} 156 163 /> 157 164 ) 158 165 }
+41 -9
src/components/ProfileHoverCard/index.web.tsx
··· 1 - import React from 'react' 1 + import React, {useCallback} from 'react' 2 2 import {View} from 'react-native' 3 3 import { 4 4 type AppBskyActorDefs, ··· 8 8 import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom' 9 9 import {msg, plural} from '@lingui/macro' 10 10 import {useLingui} from '@lingui/react' 11 + import {useNavigation} from '@react-navigation/native' 11 12 13 + import {useActorStatus} from '#/lib/actor-status' 12 14 import {isTouchDevice} from '#/lib/browser' 13 15 import {getModerationCauseKey} from '#/lib/moderation' 14 16 import {makeProfileLink} from '#/lib/routes/links' 17 + import {type NavigationProp} from '#/lib/routes/types' 15 18 import {sanitizeDisplayName} from '#/lib/strings/display-names' 16 19 import {sanitizeHandle} from '#/lib/strings/handles' 17 20 import {useProfileShadow} from '#/state/cache/profile-shadow' ··· 32 35 shouldShowKnownFollowers, 33 36 } from '#/components/KnownFollowers' 34 37 import {InlineLinkText, Link} from '#/components/Link' 38 + import {LiveStatus} from '#/components/live/LiveStatusDialog' 35 39 import {Loader} from '#/components/Loader' 36 40 import * as Pills from '#/components/Pills' 37 41 import {Portal} from '#/components/Portal' ··· 105 109 const HIDE_DURATION = 200 106 110 107 111 export function ProfileHoverCardInner(props: ProfileHoverCardProps) { 112 + const navigation = useNavigation<NavigationProp>() 113 + 108 114 const {refs, floatingStyles} = useFloating({ 109 115 middleware: floatingMiddlewares, 110 116 }) ··· 330 336 onPointerEnter={onPointerEnterCard} 331 337 onPointerLeave={onPointerLeaveCard}> 332 338 <div style={{willChange: 'transform', ...animationStyle}}> 333 - <Card did={props.did} hide={onPress} /> 339 + <Card did={props.did} hide={onPress} navigation={navigation} /> 334 340 </div> 335 341 </div> 336 342 </Portal> ··· 339 345 ) 340 346 } 341 347 342 - let Card = ({did, hide}: {did: string; hide: () => void}): React.ReactNode => { 348 + let Card = ({ 349 + did, 350 + hide, 351 + navigation, 352 + }: { 353 + did: string 354 + hide: () => void 355 + navigation: NavigationProp 356 + }): React.ReactNode => { 343 357 const t = useTheme() 344 358 345 359 const profile = useProfileQuery({did}) ··· 347 361 348 362 const data = profile.data 349 363 364 + const status = useActorStatus(data) 365 + 366 + const onPressOpenProfile = useCallback(() => { 367 + if (!status.isActive || !data) return 368 + hide() 369 + navigation.push('Profile', { 370 + name: data.handle, 371 + }) 372 + }, [hide, navigation, status, data]) 373 + 350 374 return ( 351 375 <View 352 376 style={[ 353 - a.p_lg, 377 + !status.isActive && a.p_lg, 354 378 a.border, 355 379 a.rounded_md, 356 380 a.overflow_hidden, 357 381 t.atoms.bg, 358 382 t.atoms.border_contrast_low, 359 383 t.atoms.shadow_lg, 360 - { 361 - width: 300, 362 - }, 384 + a.w_full, 385 + {maxWidth: status.isActive ? 500 : 300}, 363 386 ]}> 364 387 {data && moderationOpts ? ( 365 - <Inner profile={data} moderationOpts={moderationOpts} hide={hide} /> 388 + status.isActive ? ( 389 + <LiveStatus 390 + profile={data} 391 + embed={status.embed} 392 + padding="lg" 393 + onPressOpenProfile={onPressOpenProfile} 394 + /> 395 + ) : ( 396 + <Inner profile={data} moderationOpts={moderationOpts} hide={hide} /> 397 + ) 366 398 ) : ( 367 - <View style={[a.justify_center]}> 399 + <View style={[a.justify_center, a.align_center, {minHeight: 200}]}> 368 400 <Loader size="xl" /> 369 401 </View> 370 402 )}
+1 -1
src/components/ProfileHoverCard/types.ts
··· 1 - import React from 'react' 1 + import type React from 'react' 2 2 3 3 export type ProfileHoverCardProps = { 4 4 children: React.ReactElement
+4
src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx
··· 45 45 a.overflow_hidden, 46 46 a.pt_md, 47 47 t.atoms.bg_contrast_100, 48 + { 49 + borderTopLeftRadius: a.rounded_md.borderRadius, 50 + borderTopRightRadius: a.rounded_md.borderRadius, 51 + }, 48 52 ]}> 49 53 <GradientFill gradient={tokens.gradients.primary} /> 50 54 <ShieldIcon width={64} fill="white" style={[a.z_10]} />
+5 -1
src/components/forms/DateField/index.android.tsx
··· 1 1 import {useCallback, useImperativeHandle, useState} from 'react' 2 2 import {Keyboard} from 'react-native' 3 3 import DatePicker from 'react-native-date-picker' 4 + import {useLingui} from '@lingui/react' 4 5 5 6 import {useTheme} from '#/alf' 6 - import {DateFieldProps} from '#/components/forms/DateField/types' 7 + import {type DateFieldProps} from '#/components/forms/DateField/types' 7 8 import {toSimpleDateString} from '#/components/forms/DateField/utils' 8 9 import * as TextField from '#/components/forms/TextField' 9 10 import {DateFieldButton} from './index.shared' ··· 21 22 accessibilityHint, 22 23 maximumDate, 23 24 }: DateFieldProps) { 25 + const {i18n} = useLingui() 24 26 const t = useTheme() 25 27 const [open, setOpen] = useState(false) 26 28 ··· 80 82 onConfirm={onChangeInternal} 81 83 onCancel={onCancel} 82 84 mode="date" 85 + locale={i18n.locale} 86 + is24hourSource="locale" 83 87 testID={`${testID}-datepicker`} 84 88 aria-label={label} 85 89 accessibilityLabel={label}
+4 -3
src/components/forms/DateField/index.tsx
··· 7 7 import {atoms as a, useTheme} from '#/alf' 8 8 import {Button, ButtonText} from '#/components/Button' 9 9 import * as Dialog from '#/components/Dialog' 10 - import {DateFieldProps} from '#/components/forms/DateField/types' 10 + import {type DateFieldProps} from '#/components/forms/DateField/types' 11 11 import {toSimpleDateString} from '#/components/forms/DateField/utils' 12 12 import * as TextField from '#/components/forms/TextField' 13 13 import {DateFieldButton} from './index.shared' ··· 33 33 accessibilityHint, 34 34 maximumDate, 35 35 }: DateFieldProps) { 36 - const {_} = useLingui() 36 + const {_, i18n} = useLingui() 37 37 const t = useTheme() 38 38 const control = Dialog.useDialogControl() 39 39 ··· 83 83 <View style={[a.relative, a.w_full, a.align_center]}> 84 84 <DatePicker 85 85 timeZoneOffsetInMinutes={0} 86 - theme={t.name === 'light' ? 'light' : 'dark'} 86 + theme={t.scheme} 87 87 date={new Date(toSimpleDateString(value))} 88 88 onDateChange={onChangeInternal} 89 89 mode="date" 90 + locale={i18n.locale} 90 91 testID={`${testID}-datepicker`} 91 92 aria-label={label} 92 93 accessibilityLabel={label}
+5
src/components/icons/Live.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Live_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M2 12A9.97 9.97 0 0 1 4.929 4.93l.076-.068a1 1 0 0 1 1.407 1.406l-.07.076A7.97 7.97 0 0 0 4 12c0 2.072.786 3.958 2.078 5.38l.265.277.07.076a1 1 0 0 1-1.408 1.407l-.076-.07-.331-.346A9.97 9.97 0 0 1 2 12Zm18 0a7.97 7.97 0 0 0-2.078-5.379l-.265-.278-.07-.076a1 1 0 0 1 1.408-1.406l.076.068.331.347A9.97 9.97 0 0 1 22 12c0 2.761-1.12 5.262-2.929 7.07a1 1 0 1 1-1.414-1.413A7.97 7.97 0 0 0 20 12ZM6 12c0-1.656.673-3.158 1.758-4.243a1 1 0 0 1 1.414 1.414A3.99 3.99 0 0 0 8 12.001c0 1.035.393 1.978 1.04 2.689l.132.138.068.077a1 1 0 0 1-1.407 1.406l-.075-.069-.2-.208A5.98 5.98 0 0 1 6 12Zm10 0a3.98 3.98 0 0 0-1.04-2.69l-.132-.139-.068-.075a1 1 0 0 1 1.407-1.407l.075.068.2.208A5.98 5.98 0 0 1 18 12a5.99 5.99 0 0 1-1.758 4.243 1 1 0 0 1-1.414-1.415A3.99 3.99 0 0 0 16 12Zm-6 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Z', 5 + })
+348
src/components/live/EditLiveDialog.tsx
··· 1 + import {useMemo, useState} from 'react' 2 + import {View} from 'react-native' 3 + import {Image} from 'expo-image' 4 + import { 5 + type AppBskyActorDefs, 6 + AppBskyActorStatus, 7 + type AppBskyEmbedExternal, 8 + } from '@atproto/api' 9 + import {msg, Trans} from '@lingui/macro' 10 + import {useLingui} from '@lingui/react' 11 + import {useQuery} from '@tanstack/react-query' 12 + import {differenceInMinutes} from 'date-fns' 13 + 14 + import {getLinkMeta} from '#/lib/link-meta/link-meta' 15 + import {cleanError} from '#/lib/strings/errors' 16 + import {toNiceDomain} from '#/lib/strings/url-helpers' 17 + import {definitelyUrl} from '#/lib/strings/url-helpers' 18 + import {useAgent} from '#/state/session' 19 + import {useTickEveryMinute} from '#/state/shell' 20 + import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 21 + import {atoms as a, platform, useTheme, web} from '#/alf' 22 + import {Admonition} from '#/components/Admonition' 23 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 24 + import * as Dialog from '#/components/Dialog' 25 + import * as TextField from '#/components/forms/TextField' 26 + import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX' 27 + import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock' 28 + import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' 29 + import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 30 + import {Loader} from '#/components/Loader' 31 + import {Text} from '#/components/Typography' 32 + import { 33 + useRemoveLiveStatusMutation, 34 + useUpsertLiveStatusMutation, 35 + } from './queries' 36 + import {displayDuration, useDebouncedValue} from './utils' 37 + 38 + export function EditLiveDialog({ 39 + control, 40 + status, 41 + embed, 42 + }: { 43 + control: Dialog.DialogControlProps 44 + status: AppBskyActorDefs.StatusView 45 + embed: AppBskyEmbedExternal.View 46 + }) { 47 + return ( 48 + <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 49 + <Dialog.Handle /> 50 + <DialogInner status={status} embed={embed} /> 51 + </Dialog.Outer> 52 + ) 53 + } 54 + 55 + function DialogInner({ 56 + status, 57 + embed, 58 + }: { 59 + status: AppBskyActorDefs.StatusView 60 + embed: AppBskyEmbedExternal.View 61 + }) { 62 + const control = Dialog.useDialogContext() 63 + const {_, i18n} = useLingui() 64 + const t = useTheme() 65 + const agent = useAgent() 66 + const [liveLink, setLiveLink] = useState(embed.external.uri) 67 + const [liveLinkError, setLiveLinkError] = useState('') 68 + const [imageLoadError, setImageLoadError] = useState(false) 69 + const tick = useTickEveryMinute() 70 + 71 + const liveLinkUrl = definitelyUrl(liveLink) 72 + const debouncedUrl = useDebouncedValue(liveLinkUrl, 500) 73 + 74 + const isDirty = liveLinkUrl !== embed.external.uri 75 + 76 + const { 77 + data: linkMeta, 78 + isSuccess: hasValidLinkMeta, 79 + isLoading: linkMetaLoading, 80 + error: linkMetaError, 81 + } = useQuery({ 82 + enabled: !!debouncedUrl, 83 + queryKey: ['link-meta', debouncedUrl], 84 + queryFn: async () => { 85 + if (!debouncedUrl) return null 86 + return getLinkMeta(agent, debouncedUrl) 87 + }, 88 + }) 89 + 90 + const record = useMemo(() => { 91 + if (!AppBskyActorStatus.isRecord(status.record)) return null 92 + const validation = AppBskyActorStatus.validateRecord(status.record) 93 + if (validation.success) { 94 + return validation.value 95 + } 96 + return null 97 + }, [status]) 98 + 99 + const { 100 + mutate: goLive, 101 + isPending: isGoingLive, 102 + error: goLiveError, 103 + } = useUpsertLiveStatusMutation( 104 + record?.durationMinutes ?? 0, 105 + linkMeta, 106 + record?.createdAt, 107 + ) 108 + 109 + const { 110 + mutate: removeLiveStatus, 111 + isPending: isRemovingLiveStatus, 112 + error: removeLiveStatusError, 113 + } = useRemoveLiveStatusMutation() 114 + 115 + const {minutesUntilExpiry, expiryDateTime} = useMemo(() => { 116 + tick! 117 + 118 + const expiry = new Date(status.expiresAt ?? new Date()) 119 + return { 120 + expiryDateTime: expiry, 121 + minutesUntilExpiry: differenceInMinutes(expiry, new Date()), 122 + } 123 + }, [tick, status.expiresAt]) 124 + 125 + const submitDisabled = 126 + isGoingLive || 127 + !hasValidLinkMeta || 128 + debouncedUrl !== liveLinkUrl || 129 + isRemovingLiveStatus 130 + 131 + return ( 132 + <Dialog.ScrollableInner 133 + label={_(msg`You are Live`)} 134 + style={web({maxWidth: 420})}> 135 + <View style={[a.gap_lg]}> 136 + <View style={[a.gap_sm]}> 137 + <Text style={[a.font_bold, a.text_2xl]}> 138 + <Trans>You are Live</Trans> 139 + </Text> 140 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 141 + <ClockIcon style={[t.atoms.text_contrast_high]} size="sm" /> 142 + <Text 143 + style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}> 144 + {typeof record?.durationMinutes === 'number' ? ( 145 + <Trans> 146 + Expires in {displayDuration(i18n, minutesUntilExpiry)} at{' '} 147 + {i18n.date(expiryDateTime, { 148 + hour: 'numeric', 149 + minute: '2-digit', 150 + hour12: true, 151 + })} 152 + </Trans> 153 + ) : ( 154 + <Trans>No expiry set</Trans> 155 + )} 156 + </Text> 157 + </View> 158 + </View> 159 + <View style={[a.gap_sm]}> 160 + <View> 161 + <TextField.LabelText> 162 + <Trans>Live link</Trans> 163 + </TextField.LabelText> 164 + <TextField.Root isInvalid={!!liveLinkError || !!linkMetaError}> 165 + <TextField.Input 166 + label={_(msg`Live link`)} 167 + placeholder={_(msg`www.mylivestream.tv`)} 168 + value={liveLink} 169 + onChangeText={setLiveLink} 170 + onFocus={() => setLiveLinkError('')} 171 + onBlur={() => { 172 + if (!definitelyUrl(liveLink)) { 173 + setLiveLinkError('Invalid URL') 174 + } 175 + }} 176 + returnKeyType="done" 177 + autoCapitalize="none" 178 + autoComplete="url" 179 + autoCorrect={false} 180 + onSubmitEditing={() => { 181 + if (isDirty && !submitDisabled) { 182 + goLive() 183 + } 184 + }} 185 + /> 186 + </TextField.Root> 187 + </View> 188 + {(liveLinkError || linkMetaError) && ( 189 + <View style={[a.flex_row, a.gap_xs, a.align_center]}> 190 + <WarningIcon 191 + style={[{color: t.palette.negative_500}]} 192 + size="sm" 193 + /> 194 + <Text 195 + style={[ 196 + a.text_sm, 197 + a.leading_snug, 198 + a.flex_1, 199 + a.font_bold, 200 + {color: t.palette.negative_500}, 201 + ]}> 202 + {liveLinkError ? ( 203 + <Trans>This is not a valid link</Trans> 204 + ) : ( 205 + cleanError(linkMetaError) 206 + )} 207 + </Text> 208 + </View> 209 + )} 210 + 211 + {(linkMeta || linkMetaLoading) && ( 212 + <View 213 + style={[ 214 + a.w_full, 215 + a.border, 216 + t.atoms.border_contrast_low, 217 + t.atoms.bg, 218 + a.flex_row, 219 + a.rounded_sm, 220 + a.overflow_hidden, 221 + a.align_stretch, 222 + ]}> 223 + {(!linkMeta || linkMeta.image) && ( 224 + <View 225 + style={[ 226 + t.atoms.bg_contrast_25, 227 + {minHeight: 64, width: 114}, 228 + a.justify_center, 229 + a.align_center, 230 + ]}> 231 + {linkMeta?.image && ( 232 + <Image 233 + source={linkMeta.image} 234 + accessibilityIgnoresInvertColors 235 + transition={200} 236 + style={[a.absolute, a.inset_0]} 237 + contentFit="cover" 238 + onLoad={() => setImageLoadError(false)} 239 + onError={() => setImageLoadError(true)} 240 + /> 241 + )} 242 + {linkMeta && imageLoadError && ( 243 + <CircleXIcon 244 + style={[t.atoms.text_contrast_low]} 245 + size="xl" 246 + /> 247 + )} 248 + </View> 249 + )} 250 + <View 251 + style={[ 252 + a.flex_1, 253 + a.justify_center, 254 + a.py_sm, 255 + a.gap_xs, 256 + a.px_md, 257 + ]}> 258 + {linkMeta ? ( 259 + <> 260 + <Text 261 + numberOfLines={2} 262 + style={[a.leading_snug, a.font_bold, a.text_md]}> 263 + {linkMeta.title || linkMeta.url} 264 + </Text> 265 + <View style={[a.flex_row, a.align_center, a.gap_2xs]}> 266 + <GlobeIcon 267 + size="xs" 268 + style={[t.atoms.text_contrast_low]} 269 + /> 270 + <Text 271 + numberOfLines={1} 272 + style={[ 273 + a.text_xs, 274 + a.leading_snug, 275 + t.atoms.text_contrast_medium, 276 + ]}> 277 + {toNiceDomain(linkMeta.url)} 278 + </Text> 279 + </View> 280 + </> 281 + ) : ( 282 + <> 283 + <LoadingPlaceholder height={16} width={128} /> 284 + <LoadingPlaceholder height={12} width={72} /> 285 + </> 286 + )} 287 + </View> 288 + </View> 289 + )} 290 + </View> 291 + 292 + {goLiveError && ( 293 + <Admonition type="error">{cleanError(goLiveError)}</Admonition> 294 + )} 295 + {removeLiveStatusError && ( 296 + <Admonition type="error"> 297 + {cleanError(removeLiveStatusError)} 298 + </Admonition> 299 + )} 300 + 301 + <View 302 + style={platform({ 303 + native: [a.gap_md, a.pt_lg], 304 + web: [a.flex_row_reverse, a.gap_md, a.align_center], 305 + })}> 306 + {isDirty ? ( 307 + <Button 308 + label={_(msg`Save`)} 309 + size={platform({native: 'large', web: 'small'})} 310 + color="primary" 311 + variant="solid" 312 + onPress={() => goLive()} 313 + disabled={submitDisabled}> 314 + <ButtonText> 315 + <Trans>Save</Trans> 316 + </ButtonText> 317 + {isGoingLive && <ButtonIcon icon={Loader} />} 318 + </Button> 319 + ) : ( 320 + <Button 321 + label={_(msg`Close`)} 322 + size={platform({native: 'large', web: 'small'})} 323 + color="primary" 324 + variant="solid" 325 + onPress={() => control.close()}> 326 + <ButtonText> 327 + <Trans>Close</Trans> 328 + </ButtonText> 329 + </Button> 330 + )} 331 + <Button 332 + label={_(msg`Remove live status`)} 333 + onPress={() => removeLiveStatus()} 334 + size={platform({native: 'large', web: 'small'})} 335 + color="negative_secondary" 336 + variant="solid" 337 + disabled={isRemovingLiveStatus || isGoingLive}> 338 + <ButtonText> 339 + <Trans>Remove live status</Trans> 340 + </ButtonText> 341 + {isRemovingLiveStatus && <ButtonIcon icon={Loader} />} 342 + </Button> 343 + </View> 344 + </View> 345 + <Dialog.Close /> 346 + </Dialog.ScrollableInner> 347 + ) 348 + }
+352
src/components/live/GoLiveDialog.tsx
··· 1 + import {useCallback, useState} from 'react' 2 + import {View} from 'react-native' 3 + import {Image} from 'expo-image' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {useQuery} from '@tanstack/react-query' 7 + 8 + import {getLinkMeta} from '#/lib/link-meta/link-meta' 9 + import {cleanError} from '#/lib/strings/errors' 10 + import {toNiceDomain} from '#/lib/strings/url-helpers' 11 + import {definitelyUrl} from '#/lib/strings/url-helpers' 12 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 13 + import {useAgent} from '#/state/session' 14 + import {useTickEveryMinute} from '#/state/shell' 15 + import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 16 + import {atoms as a, ios, native, platform, useTheme, web} from '#/alf' 17 + import {Admonition} from '#/components/Admonition' 18 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19 + import * as Dialog from '#/components/Dialog' 20 + import * as TextField from '#/components/forms/TextField' 21 + import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX' 22 + import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' 23 + import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 24 + import {Loader} from '#/components/Loader' 25 + import * as ProfileCard from '#/components/ProfileCard' 26 + import * as Select from '#/components/Select' 27 + import {Text} from '#/components/Typography' 28 + import type * as bsky from '#/types/bsky' 29 + import {useUpsertLiveStatusMutation} from './queries' 30 + import {displayDuration, useDebouncedValue} from './utils' 31 + 32 + export function GoLiveDialog({ 33 + control, 34 + profile, 35 + }: { 36 + control: Dialog.DialogControlProps 37 + profile: bsky.profile.AnyProfileView 38 + }) { 39 + return ( 40 + <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 41 + <Dialog.Handle /> 42 + <DialogInner profile={profile} /> 43 + </Dialog.Outer> 44 + ) 45 + } 46 + 47 + // Possible durations: max 4 hours, 5 minute intervals 48 + const DURATIONS = Array.from({length: (4 * 60) / 5}).map((_, i) => (i + 1) * 5) 49 + 50 + function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) { 51 + const control = Dialog.useDialogContext() 52 + const {_, i18n} = useLingui() 53 + const t = useTheme() 54 + const agent = useAgent() 55 + const [liveLink, setLiveLink] = useState('') 56 + const [liveLinkError, setLiveLinkError] = useState('') 57 + const [imageLoadError, setImageLoadError] = useState(false) 58 + const [duration, setDuration] = useState(60) 59 + const moderationOpts = useModerationOpts() 60 + const tick = useTickEveryMinute() 61 + 62 + const time = useCallback( 63 + (offset: number) => { 64 + tick! 65 + 66 + const date = new Date() 67 + date.setMinutes(date.getMinutes() + offset) 68 + return i18n 69 + .date(date, {hour: 'numeric', minute: '2-digit', hour12: true}) 70 + .toLocaleUpperCase() 71 + .replace(' ', '') 72 + }, 73 + [tick, i18n], 74 + ) 75 + 76 + const onChangeDuration = useCallback((newDuration: string) => { 77 + setDuration(Number(newDuration)) 78 + }, []) 79 + 80 + const liveLinkUrl = definitelyUrl(liveLink) 81 + const debouncedUrl = useDebouncedValue(liveLinkUrl, 500) 82 + const hasLink = !!debouncedUrl 83 + 84 + const { 85 + data: linkMeta, 86 + isSuccess: hasValidLinkMeta, 87 + isLoading: linkMetaLoading, 88 + error: linkMetaError, 89 + } = useQuery({ 90 + enabled: !!debouncedUrl, 91 + queryKey: ['link-meta', debouncedUrl], 92 + queryFn: async () => { 93 + if (!debouncedUrl) return null 94 + return getLinkMeta(agent, debouncedUrl) 95 + }, 96 + }) 97 + 98 + const { 99 + mutate: goLive, 100 + isPending: isGoingLive, 101 + error: goLiveError, 102 + } = useUpsertLiveStatusMutation(duration, linkMeta) 103 + 104 + return ( 105 + <Dialog.ScrollableInner 106 + label={_(msg`Go Live`)} 107 + style={web({maxWidth: 420})}> 108 + <View style={[a.gap_xl]}> 109 + <View style={[a.gap_sm]}> 110 + <Text style={[a.font_bold, a.text_2xl]}> 111 + <Trans>Go Live</Trans> 112 + </Text> 113 + <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}> 114 + <Trans> 115 + Add a temporary live status to your profile. When someone clicks 116 + on your avatar, they’ll see information about your live event. 117 + </Trans> 118 + </Text> 119 + </View> 120 + {moderationOpts && ( 121 + <ProfileCard.Header> 122 + <ProfileCard.Avatar 123 + profile={profile} 124 + moderationOpts={moderationOpts} 125 + liveOverride 126 + disabledPreview 127 + /> 128 + <ProfileCard.NameAndHandle 129 + profile={profile} 130 + moderationOpts={moderationOpts} 131 + /> 132 + </ProfileCard.Header> 133 + )} 134 + <View style={[a.gap_sm]}> 135 + <View> 136 + <TextField.LabelText> 137 + <Trans>Live link</Trans> 138 + </TextField.LabelText> 139 + <TextField.Root isInvalid={!!liveLinkError || !!linkMetaError}> 140 + <TextField.Input 141 + label={_(msg`Live link`)} 142 + placeholder={_(msg`www.mylivestream.tv`)} 143 + value={liveLink} 144 + onChangeText={setLiveLink} 145 + onFocus={() => setLiveLinkError('')} 146 + onBlur={() => { 147 + if (!definitelyUrl(liveLink)) { 148 + setLiveLinkError('Invalid URL') 149 + } 150 + }} 151 + returnKeyType="done" 152 + autoCapitalize="none" 153 + autoComplete="url" 154 + autoCorrect={false} 155 + /> 156 + </TextField.Root> 157 + </View> 158 + {(liveLinkError || linkMetaError) && ( 159 + <View style={[a.flex_row, a.gap_xs, a.align_center]}> 160 + <WarningIcon 161 + style={[{color: t.palette.negative_500}]} 162 + size="sm" 163 + /> 164 + <Text 165 + style={[ 166 + a.text_sm, 167 + a.leading_snug, 168 + a.flex_1, 169 + a.font_bold, 170 + {color: t.palette.negative_500}, 171 + ]}> 172 + {liveLinkError ? ( 173 + <Trans>This is not a valid link</Trans> 174 + ) : ( 175 + cleanError(linkMetaError) 176 + )} 177 + </Text> 178 + </View> 179 + )} 180 + 181 + {(linkMeta || linkMetaLoading) && ( 182 + <View 183 + style={[ 184 + a.w_full, 185 + a.border, 186 + t.atoms.border_contrast_low, 187 + t.atoms.bg, 188 + a.flex_row, 189 + a.rounded_sm, 190 + a.overflow_hidden, 191 + a.align_stretch, 192 + ]}> 193 + {(!linkMeta || linkMeta.image) && ( 194 + <View 195 + style={[ 196 + t.atoms.bg_contrast_25, 197 + {minHeight: 64, width: 114}, 198 + a.justify_center, 199 + a.align_center, 200 + ]}> 201 + {linkMeta?.image && ( 202 + <Image 203 + source={linkMeta.image} 204 + accessibilityIgnoresInvertColors 205 + transition={200} 206 + style={[a.absolute, a.inset_0]} 207 + contentFit="cover" 208 + onLoad={() => setImageLoadError(false)} 209 + onError={() => setImageLoadError(true)} 210 + /> 211 + )} 212 + {linkMeta && imageLoadError && ( 213 + <CircleXIcon 214 + style={[t.atoms.text_contrast_low]} 215 + size="xl" 216 + /> 217 + )} 218 + </View> 219 + )} 220 + <View 221 + style={[ 222 + a.flex_1, 223 + a.justify_center, 224 + a.py_sm, 225 + a.gap_xs, 226 + a.px_md, 227 + ]}> 228 + {linkMeta ? ( 229 + <> 230 + <Text 231 + numberOfLines={2} 232 + style={[a.leading_snug, a.font_bold, a.text_md]}> 233 + {linkMeta.title || linkMeta.url} 234 + </Text> 235 + <View style={[a.flex_row, a.align_center, a.gap_2xs]}> 236 + <GlobeIcon 237 + size="xs" 238 + style={[t.atoms.text_contrast_low]} 239 + /> 240 + <Text 241 + numberOfLines={1} 242 + style={[ 243 + a.text_xs, 244 + a.leading_snug, 245 + t.atoms.text_contrast_medium, 246 + ]}> 247 + {toNiceDomain(linkMeta.url)} 248 + </Text> 249 + </View> 250 + </> 251 + ) : ( 252 + <> 253 + <LoadingPlaceholder height={16} width={128} /> 254 + <LoadingPlaceholder height={12} width={72} /> 255 + </> 256 + )} 257 + </View> 258 + </View> 259 + )} 260 + </View> 261 + 262 + {hasLink && ( 263 + <View> 264 + <TextField.LabelText> 265 + <Trans>Go live for</Trans> 266 + </TextField.LabelText> 267 + <Select.Root 268 + value={String(duration)} 269 + onValueChange={onChangeDuration}> 270 + <Select.Trigger label={_(msg`Select duration`)}> 271 + <Text style={[ios(a.py_xs)]}> 272 + {displayDuration(i18n, duration)} 273 + {' '} 274 + <Text style={[t.atoms.text_contrast_low]}> 275 + {time(duration)} 276 + </Text> 277 + </Text> 278 + 279 + <Select.Icon /> 280 + </Select.Trigger> 281 + <Select.Content 282 + renderItem={(item, _i, selectedValue) => { 283 + const label = displayDuration(i18n, item) 284 + return ( 285 + <Select.Item value={String(item)} label={label}> 286 + <Select.ItemIndicator /> 287 + <Select.ItemText> 288 + {label} 289 + {' '} 290 + <Text 291 + style={[ 292 + native(a.text_md), 293 + web(a.ml_xs), 294 + selectedValue === String(item) 295 + ? t.atoms.text_contrast_medium 296 + : t.atoms.text_contrast_low, 297 + a.font_normal, 298 + ]}> 299 + {time(item)} 300 + </Text> 301 + </Select.ItemText> 302 + </Select.Item> 303 + ) 304 + }} 305 + items={DURATIONS} 306 + valueExtractor={d => String(d)} 307 + /> 308 + </Select.Root> 309 + </View> 310 + )} 311 + 312 + {goLiveError && ( 313 + <Admonition type="error">{cleanError(goLiveError)}</Admonition> 314 + )} 315 + 316 + <View 317 + style={platform({ 318 + native: [a.gap_md, a.pt_lg], 319 + web: [a.flex_row_reverse, a.gap_md, a.align_center], 320 + })}> 321 + {hasLink && ( 322 + <Button 323 + label={_(msg`Go Live`)} 324 + size={platform({native: 'large', web: 'small'})} 325 + color="primary" 326 + variant="solid" 327 + onPress={() => goLive()} 328 + disabled={ 329 + isGoingLive || !hasValidLinkMeta || debouncedUrl !== liveLinkUrl 330 + }> 331 + <ButtonText> 332 + <Trans>Go Live</Trans> 333 + </ButtonText> 334 + {isGoingLive && <ButtonIcon icon={Loader} />} 335 + </Button> 336 + )} 337 + <Button 338 + label={_(msg`Cancel`)} 339 + onPress={() => control.close()} 340 + size={platform({native: 'large', web: 'small'})} 341 + color="secondary" 342 + variant={platform({native: 'solid', web: 'ghost'})}> 343 + <ButtonText> 344 + <Trans>Cancel</Trans> 345 + </ButtonText> 346 + </Button> 347 + </View> 348 + </View> 349 + <Dialog.Close /> 350 + </Dialog.ScrollableInner> 351 + ) 352 + }
+53
src/components/live/LiveIndicator.tsx
··· 1 + import {type StyleProp, View, type ViewStyle} from 'react-native' 2 + import {Trans} from '@lingui/macro' 3 + 4 + import {atoms as a, tokens, useTheme} from '#/alf' 5 + import {Text} from '#/components/Typography' 6 + 7 + export function LiveIndicator({ 8 + size = 'small', 9 + style, 10 + }: { 11 + size?: 'tiny' | 'small' | 'large' 12 + style?: StyleProp<ViewStyle> 13 + }) { 14 + const t = useTheme() 15 + 16 + const fontSize = { 17 + tiny: {fontSize: 7, letterSpacing: tokens.TRACKING}, 18 + small: a.text_2xs, 19 + large: a.text_xs, 20 + }[size] 21 + 22 + return ( 23 + <View 24 + style={[ 25 + a.absolute, 26 + a.w_full, 27 + a.align_center, 28 + a.pointer_events_none, 29 + {bottom: size === 'large' ? -8 : -5}, 30 + style, 31 + ]}> 32 + <View 33 + style={{ 34 + backgroundColor: t.palette.negative_500, 35 + paddingVertical: size === 'large' ? 2 : 1, 36 + paddingHorizontal: size === 'large' ? 4 : 3, 37 + borderRadius: size === 'large' ? 5 : tokens.borderRadius.xs, 38 + }}> 39 + <Text 40 + style={[ 41 + a.text_center, 42 + a.font_bold, 43 + fontSize, 44 + {color: t.palette.white}, 45 + ]}> 46 + <Trans comment="Live status indicator on avatar. Should be extremely short, not much space for more than 4 characters"> 47 + LIVE 48 + </Trans> 49 + </Text> 50 + </View> 51 + </View> 52 + ) 53 + }
+212
src/components/live/LiveStatusDialog.tsx
··· 1 + import {useCallback} from 'react' 2 + import {View} from 'react-native' 3 + import {Image} from 'expo-image' 4 + import {type AppBskyActorDefs, type AppBskyEmbedExternal} from '@atproto/api' 5 + import {msg, Trans} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + import {useNavigation} from '@react-navigation/native' 8 + import {useQueryClient} from '@tanstack/react-query' 9 + 10 + import {useOpenLink} from '#/lib/hooks/useOpenLink' 11 + import {type NavigationProp} from '#/lib/routes/types' 12 + import {sanitizeHandle} from '#/lib/strings/handles' 13 + import {toNiceDomain} from '#/lib/strings/url-helpers' 14 + import {logger} from '#/logger' 15 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 16 + import {unstableCacheProfileView} from '#/state/queries/profile' 17 + import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf' 18 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19 + import * as Dialog from '#/components/Dialog' 20 + import * as ProfileCard from '#/components/ProfileCard' 21 + import {Text} from '#/components/Typography' 22 + import type * as bsky from '#/types/bsky' 23 + import {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe' 24 + import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight' 25 + import {LiveIndicator} from './LiveIndicator' 26 + 27 + export function LiveStatusDialog({ 28 + control, 29 + profile, 30 + embed, 31 + }: { 32 + control: Dialog.DialogControlProps 33 + profile: bsky.profile.AnyProfileView 34 + status: AppBskyActorDefs.StatusView 35 + embed: AppBskyEmbedExternal.View 36 + }) { 37 + const navigation = useNavigation<NavigationProp>() 38 + return ( 39 + <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 40 + <Dialog.Handle difference={!!embed.external.thumb} /> 41 + <DialogInner profile={profile} embed={embed} navigation={navigation} /> 42 + </Dialog.Outer> 43 + ) 44 + } 45 + 46 + function DialogInner({ 47 + profile, 48 + embed, 49 + navigation, 50 + }: { 51 + profile: bsky.profile.AnyProfileView 52 + embed: AppBskyEmbedExternal.View 53 + navigation: NavigationProp 54 + }) { 55 + const {_} = useLingui() 56 + const control = Dialog.useDialogContext() 57 + 58 + const onPressOpenProfile = useCallback(() => { 59 + control.close(() => { 60 + navigation.push('Profile', { 61 + name: profile.handle, 62 + }) 63 + }) 64 + }, [navigation, profile.handle, control]) 65 + 66 + return ( 67 + <Dialog.ScrollableInner 68 + label={_(msg`${sanitizeHandle(profile.handle)} is live`)} 69 + contentContainerStyle={[a.pt_0, a.px_0]} 70 + style={[web({maxWidth: 420}), a.overflow_hidden]}> 71 + <LiveStatus 72 + profile={profile} 73 + embed={embed} 74 + onPressOpenProfile={onPressOpenProfile} 75 + /> 76 + <Dialog.Close /> 77 + </Dialog.ScrollableInner> 78 + ) 79 + } 80 + 81 + export function LiveStatus({ 82 + profile, 83 + embed, 84 + padding = 'xl', 85 + onPressOpenProfile, 86 + }: { 87 + profile: bsky.profile.AnyProfileView 88 + embed: AppBskyEmbedExternal.View 89 + padding?: 'lg' | 'xl' 90 + onPressOpenProfile: () => void 91 + }) { 92 + const {_} = useLingui() 93 + const t = useTheme() 94 + const queryClient = useQueryClient() 95 + const openLink = useOpenLink() 96 + const moderationOpts = useModerationOpts() 97 + 98 + return ( 99 + <> 100 + {embed.external.thumb && ( 101 + <View 102 + style={[ 103 + t.atoms.bg_contrast_25, 104 + a.w_full, 105 + {aspectRatio: 1.91}, 106 + android([ 107 + a.overflow_hidden, 108 + { 109 + borderTopLeftRadius: a.rounded_md.borderRadius, 110 + borderTopRightRadius: a.rounded_md.borderRadius, 111 + }, 112 + ]), 113 + ]}> 114 + <Image 115 + source={embed.external.thumb} 116 + contentFit="cover" 117 + style={[a.absolute, a.inset_0]} 118 + accessibilityIgnoresInvertColors 119 + /> 120 + <LiveIndicator 121 + size="large" 122 + style={[ 123 + a.absolute, 124 + {top: tokens.space.lg, left: tokens.space.lg}, 125 + a.align_start, 126 + ]} 127 + /> 128 + </View> 129 + )} 130 + <View 131 + style={[ 132 + a.gap_lg, 133 + padding === 'xl' 134 + ? [a.px_xl, !embed.external.thumb ? a.pt_2xl : a.pt_lg] 135 + : a.p_lg, 136 + ]}> 137 + <View style={[a.flex_1, a.justify_center, a.gap_2xs]}> 138 + <Text 139 + numberOfLines={3} 140 + style={[a.leading_snug, a.font_bold, a.text_xl]}> 141 + {embed.external.title || embed.external.uri} 142 + </Text> 143 + <View style={[a.flex_row, a.align_center, a.gap_2xs]}> 144 + <Globe_Stroke2_Corner0_Rounded 145 + size="xs" 146 + style={[t.atoms.text_contrast_medium]} 147 + /> 148 + <Text 149 + numberOfLines={1} 150 + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 151 + {toNiceDomain(embed.external.uri)} 152 + </Text> 153 + </View> 154 + </View> 155 + <Button 156 + label={_(msg`Watch now`)} 157 + size={platform({native: 'large', web: 'small'})} 158 + color="primary" 159 + variant="solid" 160 + onPress={() => { 161 + logger.metric('live:card:watch', {subject: profile.did}) 162 + openLink(embed.external.uri, false) 163 + }}> 164 + <ButtonText> 165 + <Trans>Watch now</Trans> 166 + </ButtonText> 167 + <ButtonIcon icon={SquareArrowTopRightIcon} /> 168 + </Button> 169 + <View style={[t.atoms.border_contrast_low, a.border_t, a.w_full]} /> 170 + {moderationOpts && ( 171 + <ProfileCard.Header> 172 + <ProfileCard.Avatar 173 + profile={profile} 174 + moderationOpts={moderationOpts} 175 + disabledPreview 176 + /> 177 + {/* Ensure wide enough on web hover */} 178 + <View style={[a.flex_1, web({minWidth: 100})]}> 179 + <ProfileCard.NameAndHandle 180 + profile={profile} 181 + moderationOpts={moderationOpts} 182 + /> 183 + </View> 184 + <Button 185 + label={_(msg`Open profile`)} 186 + size="small" 187 + color="secondary" 188 + variant="solid" 189 + onPress={() => { 190 + logger.metric('live:card:openProfile', {subject: profile.did}) 191 + unstableCacheProfileView(queryClient, profile) 192 + onPressOpenProfile() 193 + }}> 194 + <ButtonText> 195 + <Trans>Open profile</Trans> 196 + </ButtonText> 197 + </Button> 198 + </ProfileCard.Header> 199 + )} 200 + <Text 201 + style={[ 202 + a.w_full, 203 + a.text_center, 204 + t.atoms.text_contrast_low, 205 + a.text_sm, 206 + ]}> 207 + <Trans>Live feature is in beta testing</Trans> 208 + </Text> 209 + </View> 210 + </> 211 + ) 212 + }
+187
src/components/live/queries.ts
··· 1 + import { 2 + type $Typed, 3 + type AppBskyActorStatus, 4 + type AppBskyEmbedExternal, 5 + ComAtprotoRepoPutRecord, 6 + } from '@atproto/api' 7 + import {retry} from '@atproto/common-web' 8 + import {msg} from '@lingui/macro' 9 + import {useLingui} from '@lingui/react' 10 + import {useMutation, useQueryClient} from '@tanstack/react-query' 11 + 12 + import {uploadBlob} from '#/lib/api' 13 + import {imageToThumb} from '#/lib/api/resolve' 14 + import {type LinkMeta} from '#/lib/link-meta/link-meta' 15 + import {logger} from '#/logger' 16 + import {updateProfileShadow} from '#/state/cache/profile-shadow' 17 + import {useAgent, useSession} from '#/state/session' 18 + import * as Toast from '#/view/com/util/Toast' 19 + import {useDialogContext} from '#/components/Dialog' 20 + 21 + export function useUpsertLiveStatusMutation( 22 + duration: number, 23 + linkMeta: LinkMeta | null | undefined, 24 + createdAt?: string, 25 + ) { 26 + const {currentAccount} = useSession() 27 + const agent = useAgent() 28 + const queryClient = useQueryClient() 29 + const control = useDialogContext() 30 + const {_} = useLingui() 31 + 32 + return useMutation({ 33 + mutationFn: async () => { 34 + if (!currentAccount) throw new Error('Not logged in') 35 + 36 + let embed: $Typed<AppBskyEmbedExternal.Main> | undefined 37 + 38 + if (linkMeta) { 39 + let thumb 40 + 41 + if (linkMeta.image) { 42 + try { 43 + const img = await imageToThumb(linkMeta.image) 44 + if (img) { 45 + const blob = await uploadBlob( 46 + agent, 47 + img.source.path, 48 + img.source.mime, 49 + ) 50 + thumb = blob.data.blob 51 + } 52 + } catch (e: any) { 53 + logger.error(`Failed to upload thumbnail for live status`, { 54 + url: linkMeta.url, 55 + image: linkMeta.image, 56 + safeMessage: e, 57 + }) 58 + } 59 + } 60 + 61 + embed = { 62 + $type: 'app.bsky.embed.external', 63 + external: { 64 + $type: 'app.bsky.embed.external#external', 65 + title: linkMeta.title ?? '', 66 + description: linkMeta.description ?? '', 67 + uri: linkMeta.url, 68 + thumb, 69 + }, 70 + } 71 + } 72 + 73 + const record = { 74 + $type: 'app.bsky.actor.status', 75 + createdAt: createdAt ?? new Date().toISOString(), 76 + status: 'app.bsky.actor.status#live', 77 + durationMinutes: duration, 78 + embed, 79 + } satisfies AppBskyActorStatus.Record 80 + 81 + const upsert = async () => { 82 + const repo = currentAccount.did 83 + const collection = 'app.bsky.actor.status' 84 + 85 + const existing = await agent.com.atproto.repo 86 + .getRecord({repo, collection, rkey: 'self'}) 87 + .catch(_e => undefined) 88 + 89 + await agent.com.atproto.repo.putRecord({ 90 + repo, 91 + collection, 92 + rkey: 'self', 93 + record, 94 + swapRecord: existing?.data.cid || null, 95 + }) 96 + } 97 + 98 + await retry(upsert, { 99 + maxRetries: 5, 100 + retryable: e => e instanceof ComAtprotoRepoPutRecord.InvalidSwapError, 101 + }) 102 + 103 + return { 104 + record, 105 + image: linkMeta?.image, 106 + } 107 + }, 108 + onError: (e: any) => { 109 + logger.error(`Failed to upsert live status`, { 110 + url: linkMeta?.url, 111 + image: linkMeta?.image, 112 + safeMessage: e, 113 + }) 114 + }, 115 + onSuccess: ({record, image}) => { 116 + if (createdAt) { 117 + logger.metric('live:edit', {duration: record.durationMinutes}) 118 + } else { 119 + logger.metric('live:create', {duration: record.durationMinutes}) 120 + } 121 + 122 + Toast.show(_(msg`You are now live!`)) 123 + control.close(() => { 124 + if (!currentAccount) return 125 + 126 + const expiresAt = new Date(record.createdAt) 127 + expiresAt.setMinutes(expiresAt.getMinutes() + record.durationMinutes) 128 + 129 + updateProfileShadow(queryClient, currentAccount.did, { 130 + status: { 131 + $type: 'app.bsky.actor.defs#statusView', 132 + status: 'app.bsky.actor.status#live', 133 + isActive: true, 134 + expiresAt: expiresAt.toISOString(), 135 + embed: 136 + record.embed && image 137 + ? { 138 + $type: 'app.bsky.embed.external#view', 139 + external: { 140 + ...record.embed.external, 141 + $type: 'app.bsky.embed.external#viewExternal', 142 + thumb: image, 143 + }, 144 + } 145 + : undefined, 146 + record, 147 + }, 148 + }) 149 + }) 150 + }, 151 + }) 152 + } 153 + 154 + export function useRemoveLiveStatusMutation() { 155 + const {currentAccount} = useSession() 156 + const agent = useAgent() 157 + const queryClient = useQueryClient() 158 + const control = useDialogContext() 159 + const {_} = useLingui() 160 + 161 + return useMutation({ 162 + mutationFn: async () => { 163 + if (!currentAccount) throw new Error('Not logged in') 164 + 165 + await agent.app.bsky.actor.status.delete({ 166 + repo: currentAccount.did, 167 + rkey: 'self', 168 + }) 169 + }, 170 + onError: (e: any) => { 171 + logger.error(`Failed to remove live status`, { 172 + safeMessage: e, 173 + }) 174 + }, 175 + onSuccess: () => { 176 + logger.metric('live:remove', {}) 177 + Toast.show(_(msg`You are no longer live`)) 178 + control.close(() => { 179 + if (!currentAccount) return 180 + 181 + updateProfileShadow(queryClient, currentAccount.did, { 182 + status: undefined, 183 + }) 184 + }) 185 + }, 186 + }) 187 + }
+41
src/components/live/temp.ts
··· 1 + import {type AppBskyActorDefs, AppBskyEmbedExternal} from '@atproto/api' 2 + 3 + import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' 4 + import type * as bsky from '#/types/bsky' 5 + 6 + export const LIVE_DIDS: Record<string, true> = { 7 + 'did:plc:7sfnardo5xxznxc6esxc5ooe': true, // nba.com 8 + 'did:plc:gx6fyi3jcfxd7ammq2t7mzp2': true, // rtgame.bsky.social 9 + } 10 + 11 + export const LIVE_SOURCES: Record<string, true> = { 12 + 'nba.com': true, 13 + 'twitch.tv': true, 14 + } 15 + 16 + // TEMP: dumb gating 17 + export function temp__canBeLive(profile: bsky.profile.AnyProfileView) { 18 + if (__DEV__) 19 + return !!DISCOVER_DEBUG_DIDS[profile.did] || !!LIVE_DIDS[profile.did] 20 + return !!LIVE_DIDS[profile.did] 21 + } 22 + 23 + export function temp__canGoLive(profile: bsky.profile.AnyProfileView) { 24 + if (__DEV__) return true 25 + return !!LIVE_DIDS[profile.did] 26 + } 27 + 28 + // status must have a embed, and the embed must be an approved host for the status to be valid 29 + export function temp__isStatusValid(status: AppBskyActorDefs.StatusView) { 30 + if (status.status !== 'app.bsky.actor.status#live') return false 31 + try { 32 + if (AppBskyEmbedExternal.isView(status.embed)) { 33 + const url = new URL(status.embed.external.uri) 34 + return !!LIVE_SOURCES[url.hostname] 35 + } else { 36 + return false 37 + } 38 + } catch { 39 + return false 40 + } 41 + }
+37
src/components/live/utils.ts
··· 1 + import {useEffect, useState} from 'react' 2 + import {type I18n} from '@lingui/core' 3 + import {plural} from '@lingui/macro' 4 + 5 + export function displayDuration(i18n: I18n, durationInMinutes: number) { 6 + const roundedDurationInMinutes = Math.round(durationInMinutes) 7 + const hours = Math.floor(roundedDurationInMinutes / 60) 8 + const minutes = roundedDurationInMinutes % 60 9 + const minutesString = i18n._( 10 + plural(minutes, {one: '# minute', other: '# minutes'}), 11 + ) 12 + return hours > 0 13 + ? i18n._( 14 + minutes > 0 15 + ? plural(hours, { 16 + one: `# hour ${minutesString}`, 17 + other: `# hours ${minutesString}`, 18 + }) 19 + : plural(hours, { 20 + one: '# hour', 21 + other: '# hours', 22 + }), 23 + ) 24 + : minutesString 25 + } 26 + 27 + // Trailing debounce 28 + export function useDebouncedValue<T>(val: T, delayMs: number): T { 29 + const [prev, setPrev] = useState(val) 30 + 31 + useEffect(() => { 32 + const timeout = setTimeout(() => setPrev(val), delayMs) 33 + return () => clearTimeout(timeout) 34 + }, [val, delayMs]) 35 + 36 + return prev 37 + }
+51
src/lib/actor-status.ts
··· 1 + import {useMemo} from 'react' 2 + import { 3 + type $Typed, 4 + type AppBskyActorDefs, 5 + type AppBskyEmbedExternal, 6 + } from '@atproto/api' 7 + import {isAfter, parseISO} from 'date-fns' 8 + 9 + import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' 10 + import {useTickEveryMinute} from '#/state/shell' 11 + import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp' 12 + import type * as bsky from '#/types/bsky' 13 + 14 + export function useActorStatus(actor?: bsky.profile.AnyProfileView) { 15 + const shadowed = useMaybeProfileShadow(actor) 16 + const tick = useTickEveryMinute() 17 + return useMemo(() => { 18 + tick! // revalidate every minute 19 + 20 + if ( 21 + shadowed && 22 + temp__canBeLive(shadowed) && 23 + 'status' in shadowed && 24 + shadowed.status && 25 + temp__isStatusValid(shadowed.status) && 26 + isStatusStillActive(shadowed.status.expiresAt) 27 + ) { 28 + return { 29 + isActive: true, 30 + status: 'app.bsky.actor.status#live', 31 + embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this 32 + expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this 33 + record: shadowed.status.record, 34 + } satisfies AppBskyActorDefs.StatusView 35 + } else { 36 + return { 37 + status: '', 38 + isActive: false, 39 + record: {}, 40 + } satisfies AppBskyActorDefs.StatusView 41 + } 42 + }, [shadowed, tick]) 43 + } 44 + 45 + export function isStatusStillActive(timeStr: string | undefined) { 46 + if (!timeStr) return false 47 + const now = new Date() 48 + const expiry = parseISO(timeStr) 49 + 50 + return isAfter(expiry, now) 51 + }
+7 -7
src/lib/api/resolve.ts
··· 1 1 import { 2 - AppBskyFeedDefs, 3 - AppBskyGraphDefs, 4 - ComAtprotoRepoStrongRef, 2 + type AppBskyFeedDefs, 3 + type AppBskyGraphDefs, 4 + type ComAtprotoRepoStrongRef, 5 5 } from '@atproto/api' 6 6 import {AtUri} from '@atproto/api' 7 - import {BskyAgent} from '@atproto/api' 7 + import {type BskyAgent} from '@atproto/api' 8 8 9 9 import {POST_IMG_MAX} from '#/lib/constants' 10 10 import {getLinkMeta} from '#/lib/link-meta/link-meta' ··· 22 22 isBskyStartUrl, 23 23 isShortLink, 24 24 } from '#/lib/strings/url-helpers' 25 - import {ComposerImage} from '#/state/gallery' 25 + import {type ComposerImage} from '#/state/gallery' 26 26 import {createComposerImage} from '#/state/gallery' 27 - import {Gif} from '#/state/queries/tenor' 27 + import {type Gif} from '#/state/queries/tenor' 28 28 import {createGIFDescription} from '../gif-alt-text' 29 29 import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers' 30 30 ··· 213 213 } 214 214 } 215 215 216 - async function imageToThumb( 216 + export async function imageToThumb( 217 217 imageUri: string, 218 218 ): Promise<ComposerImage | undefined> { 219 219 try {
+1
src/lib/constants.ts
··· 31 31 'did:plc:3jpt2mvvsumj2r7eqk4gzzjz': true, // esb.lol 32 32 'did:plc:vjug55kidv6sye7ykr5faxxn': true, // emilyliu.me 33 33 'did:plc:tgqseeot47ymot4zro244fj3': true, // iwsmith.bsky.social 34 + 'did:plc:2dzyut5lxna5ljiaasgeuffz': true, // mrnuma.bsky.social 34 35 } 35 36 36 37 const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
+30
src/lib/strings/url-helpers.ts
··· 372 372 } 373 373 return `did:web:${hostname}` 374 374 } 375 + 376 + // passes URL.parse, and has a TLD etc 377 + export function definitelyUrl(maybeUrl: string) { 378 + try { 379 + if (maybeUrl.endsWith('.')) return null 380 + 381 + // Prepend 'https://' if the input doesn't start with a protocol 382 + if (!maybeUrl.startsWith('https://') && !maybeUrl.startsWith('http://')) { 383 + maybeUrl = 'https://' + maybeUrl 384 + } 385 + 386 + const url = new URL(maybeUrl) 387 + 388 + // Extract the hostname and split it into labels 389 + const hostname = url.hostname 390 + const labels = hostname.split('.') 391 + 392 + // Ensure there are at least two labels (e.g., 'example' and 'com') 393 + if (labels.length < 2) return null 394 + 395 + const tld = labels[labels.length - 1] 396 + 397 + // Check that the TLD is at least two characters long and contains only letters 398 + if (!/^[a-z]{2,}$/i.test(tld)) return null 399 + 400 + return url.toString() 401 + } catch { 402 + return null 403 + } 404 + }
+9
src/logger/metrics.ts
··· 383 383 } 384 384 'verification:settings:hideBadges': {} 385 385 'verification:settings:unHideBadges': {} 386 + 387 + 'live:create': {duration: number} 388 + 'live:edit': {} 389 + 'live:remove': {} 390 + 'live:card:open': {subject: string; from: 'post' | 'profile'} 391 + 'live:card:watch': {subject: string} 392 + 'live:card:openProfile': {subject: string} 393 + 'live:view:profile': {subject: string} 394 + 'live:view:post': {subject: string; feed?: string} 386 395 }
+5 -1
src/screens/Profile/Header/ProfileHeaderStandard.tsx
··· 9 9 import {msg, Trans} from '@lingui/macro' 10 10 import {useLingui} from '@lingui/react' 11 11 12 + import {useActorStatus} from '#/lib/actor-status' 12 13 import {sanitizeDisplayName} from '#/lib/strings/display-names' 13 14 import {sanitizeHandle} from '#/lib/strings/handles' 14 15 import {logger} from '#/logger' ··· 138 139 [currentAccount, profile], 139 140 ) 140 141 142 + const {isActive: live} = useActorStatus(profile) 143 + 141 144 return ( 142 145 <ProfileHeaderShell 143 146 profile={profile} ··· 228 231 ) : null} 229 232 <ProfileMenu profile={profile} /> 230 233 </View> 231 - <View style={[a.flex_col, a.gap_2xs, a.pt_2xs, a.pb_sm]}> 234 + <View 235 + style={[a.flex_col, a.gap_2xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}> 232 236 <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> 233 237 <Text 234 238 emoji
+78 -22
src/screens/Profile/Header/Shell.tsx
··· 1 - import React, {memo} from 'react' 1 + import React, {memo, useEffect} from 'react' 2 2 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' 3 - import {MeasuredDimensions, runOnJS, runOnUI} from 'react-native-reanimated' 3 + import { 4 + type MeasuredDimensions, 5 + runOnJS, 6 + runOnUI, 7 + } from 'react-native-reanimated' 4 8 import {useSafeAreaInsets} from 'react-native-safe-area-context' 5 - import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' 9 + import {type AppBskyActorDefs, type ModerationDecision} from '@atproto/api' 6 10 import {msg} from '@lingui/macro' 7 11 import {useLingui} from '@lingui/react' 8 12 import {useNavigation} from '@react-navigation/native' 9 13 14 + import {useActorStatus} from '#/lib/actor-status' 10 15 import {BACK_HITSLOP} from '#/lib/constants' 16 + import {useHaptics} from '#/lib/haptics' 11 17 import {measureHandle, useHandleRef} from '#/lib/hooks/useHandleRef' 12 - import {NavigationProp} from '#/lib/routes/types' 18 + import {type NavigationProp} from '#/lib/routes/types' 19 + import {logger} from '#/logger' 13 20 import {isIOS} from '#/platform/detection' 14 - import {Shadow} from '#/state/cache/types' 21 + import {type Shadow} from '#/state/cache/types' 15 22 import {useLightboxControls} from '#/state/lightbox' 16 23 import {useSession} from '#/state/session' 17 24 import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 18 25 import {UserAvatar} from '#/view/com/util/UserAvatar' 19 26 import {UserBanner} from '#/view/com/util/UserBanner' 20 27 import {atoms as a, platform, useTheme} from '#/alf' 28 + import {useDialogControl} from '#/components/Dialog' 21 29 import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' 30 + import {EditLiveDialog} from '#/components/live/EditLiveDialog' 31 + import {LiveIndicator} from '#/components/live/LiveIndicator' 32 + import {LiveStatusDialog} from '#/components/live/LiveStatusDialog' 22 33 import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' 23 34 import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' 24 35 import {GrowableAvatar} from './GrowableAvatar' ··· 45 56 const {openLightbox} = useLightboxControls() 46 57 const navigation = useNavigation<NavigationProp>() 47 58 const {top: topInset} = useSafeAreaInsets() 59 + const playHaptic = useHaptics() 60 + const liveStatusControl = useDialogControl() 48 61 49 62 const aviRef = useHandleRef() 50 63 ··· 79 92 [openLightbox], 80 93 ) 81 94 82 - const onPressAvi = React.useCallback(() => { 83 - const modui = moderation.ui('avatar') 84 - const avatar = profile.avatar 85 - if (avatar && !(modui.blur && modui.noOverride)) { 86 - const aviHandle = aviRef.current 87 - runOnUI(() => { 88 - 'worklet' 89 - const rect = measureHandle(aviHandle) 90 - runOnJS(_openLightbox)(avatar, rect) 91 - })() 92 - } 93 - }, [profile, moderation, _openLightbox, aviRef]) 94 - 95 95 const isMe = React.useMemo( 96 96 () => currentAccount?.did === profile.did, 97 97 [currentAccount, profile], 98 98 ) 99 99 100 + const live = useActorStatus(profile) 101 + 102 + useEffect(() => { 103 + if (live.isActive) { 104 + logger.metric('live:view:profile', {subject: profile.did}) 105 + } 106 + }, [live.isActive, profile.did]) 107 + 108 + const onPressAvi = React.useCallback(() => { 109 + if (live.isActive) { 110 + playHaptic('Light') 111 + logger.metric('live:card:open', {subject: profile.did, from: 'profile'}) 112 + liveStatusControl.open() 113 + } else { 114 + const modui = moderation.ui('avatar') 115 + const avatar = profile.avatar 116 + if (avatar && !(modui.blur && modui.noOverride)) { 117 + const aviHandle = aviRef.current 118 + runOnUI(() => { 119 + 'worklet' 120 + const rect = measureHandle(aviHandle) 121 + runOnJS(_openLightbox)(avatar, rect) 122 + })() 123 + } 124 + } 125 + }, [ 126 + profile, 127 + moderation, 128 + _openLightbox, 129 + aviRef, 130 + liveStatusControl, 131 + live, 132 + playHaptic, 133 + ]) 134 + 100 135 return ( 101 136 <View style={t.atoms.bg} pointerEvents={isIOS ? 'auto' : 'box-none'}> 102 137 <View ··· 170 205 <View 171 206 style={[ 172 207 t.atoms.bg, 173 - {borderColor: t.atoms.bg.backgroundColor}, 208 + a.rounded_full, 209 + { 210 + borderWidth: live.isActive ? 3 : 2, 211 + borderColor: live.isActive 212 + ? t.palette.negative_500 213 + : t.atoms.bg.backgroundColor, 214 + }, 174 215 styles.avi, 175 216 profile.associated?.labeler && styles.aviLabeler, 176 217 ]}> 177 218 <View ref={aviRef} collapsable={false}> 178 219 <UserAvatar 179 220 type={profile.associated?.labeler ? 'labeler' : 'user'} 180 - size={90} 221 + size={live.isActive ? 88 : 90} 181 222 avatar={profile.avatar} 182 223 moderation={moderation.ui('avatar')} 183 224 /> 225 + {live.isActive && <LiveIndicator size="large" />} 184 226 </View> 185 227 </View> 186 228 </TouchableWithoutFeedback> 187 229 </GrowableAvatar> 230 + 231 + {live.isActive && 232 + (isMe ? ( 233 + <EditLiveDialog 234 + control={liveStatusControl} 235 + status={live} 236 + embed={live.embed} 237 + /> 238 + ) : ( 239 + <LiveStatusDialog 240 + control={liveStatusControl} 241 + status={live} 242 + embed={live.embed} 243 + profile={profile} 244 + /> 245 + ))} 188 246 </View> 189 247 ) 190 248 } ··· 219 277 avi: { 220 278 width: 94, 221 279 height: 94, 222 - borderRadius: 47, 223 - borderWidth: 2, 224 280 }, 225 281 aviLabeler: { 226 282 borderRadius: 10,
+6
src/screens/Settings/Settings.tsx
··· 8 8 import {useNavigation} from '@react-navigation/native' 9 9 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 10 10 11 + import {useActorStatus} from '#/lib/actor-status' 11 12 import {IS_INTERNAL} from '#/lib/app-info' 12 13 import {HELP_DESK_URL} from '#/lib/constants' 13 14 import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' ··· 287 288 const verificationState = useFullVerificationState({ 288 289 profile: shadow, 289 290 }) 291 + const {isActive: live} = useActorStatus(profile) 290 292 291 293 if (!moderationOpts) return null 292 294 ··· 303 305 avatar={shadow.avatar} 304 306 moderation={moderation.ui('avatar')} 305 307 type={shadow.associated?.labeler ? 'labeler' : 'user'} 308 + live={live} 306 309 /> 307 310 308 311 <View ··· 468 471 const moderationOpts = useModerationOpts() 469 472 const removePromptControl = Prompt.usePromptControl() 470 473 const {removeAccount} = useSessionApi() 474 + const {isActive: live} = useActorStatus(profile) 471 475 472 476 const onSwitchAccount = () => { 473 477 if (pendingDid) return ··· 485 489 avatar={profile.avatar} 486 490 moderation={moderateProfile(profile, moderationOpts).ui('avatar')} 487 491 type={profile.associated?.labeler ? 'labeler' : 'user'} 492 + live={live} 493 + hideLiveBadge 488 494 /> 489 495 ) : ( 490 496 <View style={[{width: 28}]} />
+7
src/state/cache/profile-shadow.ts
··· 31 31 muted: boolean | undefined 32 32 blockingUri: string | undefined 33 33 verification: AppBskyActorDefs.VerificationState 34 + status: AppBskyActorDefs.StatusView | undefined 34 35 } 35 36 36 37 const shadows: WeakMap< ··· 138 139 }, 139 140 verification: 140 141 'verification' in shadow ? shadow.verification : profile.verification, 142 + status: 143 + 'status' in shadow 144 + ? shadow.status 145 + : 'status' in profile 146 + ? profile.status 147 + : undefined, 141 148 }) 142 149 } 143 150
+5
src/view/com/post-thread/PostThreadItem.tsx
··· 16 16 import {msg, Plural, Trans} from '@lingui/macro' 17 17 import {useLingui} from '@lingui/react' 18 18 19 + import {useActorStatus} from '#/lib/actor-status' 19 20 import {MAX_POST_LINES} from '#/lib/constants' 20 21 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 21 22 import {useOpenLink} from '#/lib/hooks/useOpenLink' ··· 286 287 const onPressShowMore = React.useCallback(() => { 287 288 setLimitLines(false) 288 289 }, [setLimitLines]) 290 + 291 + const {isActive: live} = useActorStatus(post.author) 289 292 290 293 if (!record) { 291 294 return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} /> ··· 330 333 profile={post.author} 331 334 moderation={moderation.ui('avatar')} 332 335 type={post.author.associated?.labeler ? 'labeler' : 'user'} 336 + live={live} 333 337 /> 334 338 <View style={[a.flex_1]}> 335 339 <View style={[a.flex_row, a.align_center]}> ··· 575 579 profile={post.author} 576 580 moderation={moderation.ui('avatar')} 577 581 type={post.author.associated?.labeler ? 'labeler' : 'user'} 582 + live={live} 578 583 /> 579 584 580 585 {showChildReplyLine && (
+6 -9
src/view/com/post/Post.tsx
··· 27 27 import {useModerationOpts} from '#/state/preferences/moderation-opts' 28 28 import {precacheProfile} from '#/state/queries/profile' 29 29 import {useSession} from '#/state/session' 30 - import {AviFollowButton} from '#/view/com/posts/AviFollowButton' 31 30 import {atoms as a} from '#/alf' 32 31 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 33 32 import {RichText} from '#/components/RichText' ··· 174 173 {showReplyLine && <View style={styles.replyLine} />} 175 174 <View style={styles.layout}> 176 175 <View style={styles.layoutAvi}> 177 - <AviFollowButton author={post.author} moderation={moderation}> 178 - <PreviewableUserAvatar 179 - size={42} 180 - profile={post.author} 181 - moderation={moderation.ui('avatar')} 182 - type={post.author.associated?.labeler ? 'labeler' : 'user'} 183 - /> 184 - </AviFollowButton> 176 + <PreviewableUserAvatar 177 + size={42} 178 + profile={post.author} 179 + moderation={moderation.ui('avatar')} 180 + type={post.author.associated?.labeler ? 'labeler' : 'user'} 181 + /> 185 182 </View> 186 183 <View style={styles.layoutContent}> 187 184 <PostMeta
-143
src/view/com/posts/AviFollowButton.tsx
··· 1 - import React from 'react' 2 - import {View} from 'react-native' 3 - import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' 4 - import {msg} from '@lingui/macro' 5 - import {useLingui} from '@lingui/react' 6 - import {useNavigation} from '@react-navigation/native' 7 - 8 - import {NavigationProp} from '#/lib/routes/types' 9 - import {sanitizeDisplayName} from '#/lib/strings/display-names' 10 - import {useProfileShadow} from '#/state/cache/profile-shadow' 11 - import {useSession} from '#/state/session' 12 - import { 13 - DropdownItem, 14 - NativeDropdown, 15 - } from '#/view/com/util/forms/NativeDropdown' 16 - import * as Toast from '#/view/com/util/Toast' 17 - import {atoms as a, select, useTheme} from '#/alf' 18 - import {Button} from '#/components/Button' 19 - import {useFollowMethods} from '#/components/hooks/useFollowMethods' 20 - import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 21 - 22 - export function AviFollowButton({ 23 - author, 24 - moderation, 25 - children, 26 - }: { 27 - author: AppBskyActorDefs.ProfileViewBasic 28 - moderation: ModerationDecision 29 - children: React.ReactNode 30 - }) { 31 - const {_} = useLingui() 32 - const t = useTheme() 33 - const profile = useProfileShadow(author) 34 - const {follow} = useFollowMethods({ 35 - profile: profile, 36 - logContext: 'AvatarButton', 37 - }) 38 - const {currentAccount, hasSession} = useSession() 39 - const navigation = useNavigation<NavigationProp>() 40 - 41 - const name = sanitizeDisplayName( 42 - profile.displayName || profile.handle, 43 - moderation.ui('displayName'), 44 - ) 45 - const isFollowing = 46 - profile.viewer?.following || profile.did === currentAccount?.did 47 - 48 - function onPress() { 49 - follow() 50 - Toast.show(_(msg`Following ${name}`)) 51 - } 52 - 53 - const items: DropdownItem[] = [ 54 - { 55 - label: _(msg`View profile`), 56 - onPress: () => { 57 - navigation.navigate('Profile', {name: profile.did}) 58 - }, 59 - icon: { 60 - ios: { 61 - name: 'arrow.up.right.square', 62 - }, 63 - android: '', 64 - web: ['far', 'arrow-up-right-from-square'], 65 - }, 66 - }, 67 - { 68 - label: _(msg`Follow ${name}`), 69 - onPress: onPress, 70 - icon: { 71 - ios: { 72 - name: 'person.badge.plus', 73 - }, 74 - android: '', 75 - web: ['far', 'user-plus'], 76 - }, 77 - }, 78 - ] 79 - 80 - return hasSession ? ( 81 - <View style={a.relative}> 82 - {children} 83 - 84 - {!isFollowing && ( 85 - <Button 86 - label={_(msg`Open ${name} profile shortcut menu`)} 87 - style={[ 88 - a.rounded_full, 89 - a.absolute, 90 - { 91 - bottom: -7, 92 - right: -7, 93 - }, 94 - ]}> 95 - <NativeDropdown items={items}> 96 - <View 97 - style={[ 98 - { 99 - // An asymmetric hit slop 100 - // to prioritize bottom right taps. 101 - paddingTop: 2, 102 - paddingLeft: 2, 103 - paddingBottom: 6, 104 - paddingRight: 6, 105 - }, 106 - a.align_center, 107 - a.justify_center, 108 - a.rounded_full, 109 - ]}> 110 - <View 111 - style={[ 112 - a.rounded_full, 113 - a.align_center, 114 - select(t.name, { 115 - light: t.atoms.bg_contrast_100, 116 - dim: t.atoms.bg_contrast_100, 117 - dark: t.atoms.bg_contrast_200, 118 - }), 119 - { 120 - borderWidth: 1, 121 - borderColor: t.atoms.bg.backgroundColor, 122 - }, 123 - ]}> 124 - <Plus 125 - size="sm" 126 - fill={ 127 - select(t.name, { 128 - light: t.atoms.bg_contrast_600, 129 - dim: t.atoms.bg_contrast_500, 130 - dark: t.atoms.bg_contrast_600, 131 - }).backgroundColor 132 - } 133 - /> 134 - </View> 135 - </View> 136 - </NativeDropdown> 137 - </Button> 138 - )} 139 - </View> 140 - ) : ( 141 - children 142 - ) 143 - }
-5
src/view/com/posts/AviFollowButton.web.tsx
··· 1 - import React from 'react' 2 - 3 - export function AviFollowButton({children}: {children: React.ReactNode}) { 4 - return children 5 - }
+29 -3
src/view/com/posts/PostFeed.tsx
··· 1 - import React, {memo, useCallback} from 'react' 1 + import React, {memo, useCallback, useRef} from 'react' 2 2 import { 3 3 ActivityIndicator, 4 4 AppState, ··· 19 19 import {useLingui} from '@lingui/react' 20 20 import {useQueryClient} from '@tanstack/react-query' 21 21 22 + import {isStatusStillActive} from '#/lib/actor-status' 22 23 import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' 23 24 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 24 25 import {logEvent} from '#/lib/statsig/statsig' ··· 52 53 } from '#/components/feeds/PostFeedVideoGridRow' 53 54 import {TrendingInterstitial} from '#/components/interstitials/Trending' 54 55 import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' 56 + import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp' 55 57 import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' 56 58 import {FeedShutdownMsg} from './FeedShutdownMsg' 57 59 import {PostFeedErrorMessage} from './PostFeedErrorMessage' ··· 775 777 ) 776 778 }, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset]) 777 779 780 + const seenActorWithStatusRef = useRef<Set<string>>(new Set()) 781 + const onItemSeen = useCallback( 782 + (item: FeedRow) => { 783 + feedFeedback.onItemSeen(item) 784 + if (item.type === 'sliceItem') { 785 + const actor = item.slice.items[item.indexInSlice].post.author 786 + if ( 787 + actor.status && 788 + temp__canBeLive(actor) && 789 + temp__isStatusValid(actor.status) && 790 + isStatusStillActive(actor.status.expiresAt) 791 + ) { 792 + if (!seenActorWithStatusRef.current.has(actor.did)) { 793 + seenActorWithStatusRef.current.add(actor.did) 794 + logger.metric('live:view:post', { 795 + subject: actor.did, 796 + feed, 797 + }) 798 + } 799 + } 800 + } 801 + }, 802 + [feedFeedback, feed], 803 + ) 804 + 778 805 return ( 779 806 <View testID={testID} style={style}> 780 807 <List ··· 797 824 onEndReachedThreshold={2} // number of posts left to trigger load more 798 825 removeClippedSubviews={true} 799 826 extraData={extraData} 800 - // @ts-ignore our .web version only -prf 801 827 desktopFixedHeight={ 802 828 desktopFixedHeightOffset ? desktopFixedHeightOffset : true 803 829 } ··· 805 831 windowSize={9} 806 832 maxToRenderPerBatch={isIOS ? 5 : 1} 807 833 updateCellsBatchingPeriod={40} 808 - onItemSeen={feedFeedback.onItemSeen} 834 + onItemSeen={onItemSeen} 809 835 /> 810 836 </View> 811 837 )
+12 -11
src/view/com/posts/PostFeedItem.tsx
··· 17 17 import {useLingui} from '@lingui/react' 18 18 import {useQueryClient} from '@tanstack/react-query' 19 19 20 + import {useActorStatus} from '#/lib/actor-status' 20 21 import {isReasonFeedSource, type ReasonFeedSource} from '#/lib/api/feed/types' 21 22 import {MAX_POST_LINES} from '#/lib/constants' 22 23 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' ··· 53 54 import {SubtleWebHover} from '#/components/SubtleWebHover' 54 55 import * as bsky from '#/types/bsky' 55 56 import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link' 56 - import {AviFollowButton} from './AviFollowButton' 57 57 58 58 interface FeedItemProps { 59 59 record: AppBskyFeedPost.Record ··· 251 251 ? rootPost.threadgate.record 252 252 : undefined 253 253 254 + const {isActive: live} = useActorStatus(post.author) 255 + 254 256 return ( 255 257 <Link 256 258 testID={`feedItem-by-${post.author.handle}`} ··· 381 383 382 384 <View style={styles.layout}> 383 385 <View style={styles.layoutAvi}> 384 - <AviFollowButton author={post.author} moderation={moderation}> 385 - <PreviewableUserAvatar 386 - size={42} 387 - profile={post.author} 388 - moderation={moderation.ui('avatar')} 389 - type={post.author.associated?.labeler ? 'labeler' : 'user'} 390 - onBeforePress={onOpenAuthor} 391 - /> 392 - </AviFollowButton> 386 + <PreviewableUserAvatar 387 + size={42} 388 + profile={post.author} 389 + moderation={moderation.ui('avatar')} 390 + type={post.author.associated?.labeler ? 'labeler' : 'user'} 391 + onBeforePress={onOpenAuthor} 392 + live={live} 393 + /> 393 394 {isThreadParent && ( 394 395 <View 395 396 style={[ ··· 397 398 { 398 399 flexGrow: 1, 399 400 backgroundColor: pal.colors.replyLine, 400 - marginTop: 4, 401 + marginTop: live ? 8 : 4, 401 402 }, 402 403 ]} 403 404 />
+38
src/view/com/profile/ProfileMenu.tsx
··· 5 5 import {useNavigation} from '@react-navigation/native' 6 6 import {useQueryClient} from '@tanstack/react-query' 7 7 8 + import {useActorStatus} from '#/lib/actor-status' 8 9 import {HITSLOP_20} from '#/lib/constants' 9 10 import {makeProfileLink} from '#/lib/routes/links' 10 11 import {type NavigationProp} from '#/lib/routes/types' ··· 23 24 import {EventStopper} from '#/view/com/util/EventStopper' 24 25 import * as Toast from '#/view/com/util/Toast' 25 26 import {Button, ButtonIcon} from '#/components/Button' 27 + import {useDialogControl} from '#/components/Dialog' 26 28 import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 27 29 import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck' 28 30 import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX' 29 31 import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 30 32 import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' 31 33 import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' 34 + import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' 32 35 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' 33 36 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 34 37 import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2' ··· 38 41 } from '#/components/icons/Person' 39 42 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 40 43 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 44 + import {EditLiveDialog} from '#/components/live/EditLiveDialog' 45 + import {GoLiveDialog} from '#/components/live/GoLiveDialog' 46 + import {temp__canGoLive} from '#/components/live/temp' 41 47 import * as Menu from '#/components/Menu' 42 48 import { 43 49 ReportDialog, ··· 77 83 78 84 const blockPromptControl = Prompt.usePromptControl() 79 85 const loggedOutWarningPromptControl = Prompt.usePromptControl() 86 + const goLiveDialogControl = useDialogControl() 80 87 81 88 const showLoggedOutWarning = React.useMemo(() => { 82 89 return ( ··· 201 208 return v.issuer === currentAccount?.did 202 209 }) ?? [] 203 210 211 + const status = useActorStatus(profile) 212 + 204 213 return ( 205 214 <EventStopper onKeyDown={false}> 206 215 <Menu.Root> ··· 290 299 </Menu.ItemText> 291 300 <Menu.ItemIcon icon={List} /> 292 301 </Menu.Item> 302 + {isSelf && temp__canGoLive(profile) && ( 303 + <Menu.Item 304 + testID="profileHeaderDropdownListAddRemoveBtn" 305 + label={ 306 + status.isActive 307 + ? _(msg`Edit live status`) 308 + : _(msg`Go live`) 309 + } 310 + onPress={goLiveDialogControl.open}> 311 + <Menu.ItemText> 312 + {status.isActive ? ( 313 + <Trans>Edit live status</Trans> 314 + ) : ( 315 + <Trans>Go live</Trans> 316 + )} 317 + </Menu.ItemText> 318 + <Menu.ItemIcon icon={LiveIcon} /> 319 + </Menu.Item> 320 + )} 293 321 {verification.viewer.role === 'verifier' && 294 322 !verification.profile.isViewer && 295 323 (verification.viewer.hasIssuedVerification ? ( ··· 456 484 profile={profile} 457 485 verifications={currentAccountVerifications} 458 486 /> 487 + 488 + {status.isActive ? ( 489 + <EditLiveDialog 490 + control={goLiveDialogControl} 491 + status={status} 492 + embed={status.embed} 493 + /> 494 + ) : ( 495 + <GoLiveDialog control={goLiveDialogControl} profile={profile} /> 496 + )} 459 497 </EventStopper> 460 498 ) 461 499 }
+4
src/view/com/util/PostMeta.tsx
··· 6 6 import {useQueryClient} from '@tanstack/react-query' 7 7 import type React from 'react' 8 8 9 + import {useActorStatus} from '#/lib/actor-status' 9 10 import {makeProfileLink} from '#/lib/routes/links' 10 11 import {forceLTR} from '#/lib/strings/bidi' 11 12 import {NON_BREAKING_SPACE} from '#/lib/strings/constants' ··· 55 56 56 57 const timestampLabel = niceDate(i18n, opts.timestamp) 57 58 const verification = useSimpleVerificationState({profile: author}) 59 + const {isActive: live} = useActorStatus(author) 58 60 59 61 return ( 60 62 <View ··· 74 76 profile={author} 75 77 moderation={opts.moderation?.ui('avatar')} 76 78 type={author.associated?.labeler ? 'labeler' : 'user'} 79 + live={live} 80 + hideLiveBadge 77 81 /> 78 82 </View> 79 83 )}
+75 -13
src/view/com/util/UserAvatar.tsx
··· 14 14 import {useLingui} from '@lingui/react' 15 15 import {useQueryClient} from '@tanstack/react-query' 16 16 17 + import {useActorStatus} from '#/lib/actor-status' 18 + import {isTouchDevice} from '#/lib/browser' 19 + import {useHaptics} from '#/lib/haptics' 17 20 import { 18 21 useCameraPermission, 19 22 usePhotoLibraryPermission, ··· 22 25 import {openCamera, openCropper, openPicker} from '#/lib/media/picker' 23 26 import {type PickerImage} from '#/lib/media/picker.shared' 24 27 import {makeProfileLink} from '#/lib/routes/links' 28 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 29 + import {sanitizeHandle} from '#/lib/strings/handles' 25 30 import {logger} from '#/logger' 26 31 import {isAndroid, isNative, isWeb} from '#/platform/detection' 27 32 import { ··· 33 38 import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' 34 39 import {HighPriorityImage} from '#/view/com/util/images/Image' 35 40 import {atoms as a, tokens, useTheme} from '#/alf' 41 + import {Button} from '#/components/Button' 36 42 import {useDialogControl} from '#/components/Dialog' 37 43 import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' 38 44 import { ··· 42 48 import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive' 43 49 import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 44 50 import {Link} from '#/components/Link' 51 + import {LiveIndicator} from '#/components/live/LiveIndicator' 52 + import {LiveStatusDialog} from '#/components/live/LiveStatusDialog' 45 53 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 46 54 import * as Menu from '#/components/Menu' 47 55 import {ProfileHoverCard} from '#/components/ProfileHoverCard' ··· 54 62 shape?: 'circle' | 'square' 55 63 size: number 56 64 avatar?: string | null 65 + live?: boolean 66 + hideLiveBadge?: boolean 57 67 } 58 68 59 69 interface UserAvatarProps extends BaseUserAvatarProps { ··· 196 206 usePlainRNImage = false, 197 207 onLoad, 198 208 style, 209 + live, 210 + hideLiveBadge, 199 211 }: UserAvatarProps): React.ReactNode => { 200 212 const t = useTheme() 201 - const backgroundColor = t.palette.contrast_25 202 213 const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') 203 214 204 215 const aviStyle = useMemo(() => { 216 + let borderRadius 205 217 if (finalShape === 'square') { 206 - return { 207 - width: size, 208 - height: size, 209 - borderRadius: size > 32 ? 8 : 3, 210 - backgroundColor, 211 - } 218 + borderRadius = size > 32 ? 8 : 3 219 + } else { 220 + borderRadius = Math.floor(size / 2) 212 221 } 222 + 213 223 return { 214 224 width: size, 215 225 height: size, 216 - borderRadius: Math.floor(size / 2), 217 - backgroundColor, 226 + borderRadius, 227 + backgroundColor: t.palette.contrast_25, 218 228 } 219 - }, [finalShape, size, backgroundColor]) 229 + }, [finalShape, size, t]) 230 + 231 + const borderStyle = useMemo(() => { 232 + return [ 233 + {borderRadius: aviStyle.borderRadius}, 234 + live && { 235 + borderColor: t.palette.negative_500, 236 + borderWidth: size > 16 ? 2 : 1, 237 + opacity: 1, 238 + }, 239 + ] 240 + }, [aviStyle.borderRadius, live, t, size]) 220 241 221 242 const alert = useMemo(() => { 222 243 if (!moderation?.alert) { ··· 277 298 onLoad={onLoad} 278 299 /> 279 300 )} 280 - <MediaInsetBorder style={[{borderRadius: aviStyle.borderRadius}]} /> 301 + <MediaInsetBorder style={borderStyle} /> 302 + {live && size > 16 && !hideLiveBadge && ( 303 + <LiveIndicator size={size > 32 ? 'small' : 'tiny'} /> 304 + )} 281 305 {alert} 282 306 </View> 283 307 ) : ( 284 308 <View style={containerStyle}> 285 309 <DefaultAvatar type={type} shape={finalShape} size={size} /> 310 + <MediaInsetBorder style={borderStyle} /> 311 + {live && size > 16 && !hideLiveBadge && ( 312 + <LiveIndicator size={size > 32 ? 'small' : 'tiny'} /> 313 + )} 286 314 {alert} 287 315 </View> 288 316 ) ··· 486 514 disableHoverCard, 487 515 disableNavigation, 488 516 onBeforePress, 517 + live, 489 518 ...rest 490 519 }: PreviewableUserAvatarProps): React.ReactNode => { 491 520 const {_} = useLingui() 492 521 const queryClient = useQueryClient() 522 + const status = useActorStatus(profile) 523 + const liveControl = useDialogControl() 524 + const playHaptic = useHaptics() 493 525 494 - const onPress = React.useCallback(() => { 526 + const onPress = useCallback(() => { 495 527 onBeforePress?.() 496 528 unstableCacheProfileView(queryClient, profile) 497 529 }, [profile, queryClient, onBeforePress]) 498 530 531 + const onOpenLiveStatus = useCallback(() => { 532 + playHaptic('Light') 533 + logger.metric('live:card:open', {subject: profile.did, from: 'post'}) 534 + liveControl.open() 535 + }, [liveControl, playHaptic, profile.did]) 536 + 499 537 const avatarEl = ( 500 538 <UserAvatar 501 539 avatar={profile.avatar} 502 540 moderation={moderation} 503 541 type={profile.associated?.labeler ? 'labeler' : 'user'} 542 + live={status.isActive || live} 504 543 {...rest} 505 544 /> 506 545 ) ··· 509 548 <ProfileHoverCard did={profile.did} disable={disableHoverCard}> 510 549 {disableNavigation ? ( 511 550 avatarEl 551 + ) : status.isActive && (isNative || isTouchDevice) ? ( 552 + <> 553 + <Button 554 + label={_( 555 + msg`${sanitizeDisplayName( 556 + profile.displayName || sanitizeHandle(profile.handle), 557 + )}'s avatar`, 558 + )} 559 + accessibilityHint={_(msg`Opens live status dialog`)} 560 + onPress={onOpenLiveStatus}> 561 + {avatarEl} 562 + </Button> 563 + <LiveStatusDialog 564 + control={liveControl} 565 + profile={profile} 566 + status={status} 567 + embed={status.embed} 568 + /> 569 + </> 512 570 ) : ( 513 571 <Link 514 - label={_(msg`${profile.displayName || profile.handle}'s avatar`)} 572 + label={_( 573 + msg`${sanitizeDisplayName( 574 + profile.displayName || sanitizeHandle(profile.handle), 575 + )}'s avatar`, 576 + )} 515 577 accessibilityHint={_(msg`Opens this profile`)} 516 578 to={makeProfileLink({ 517 579 did: profile.did,
+31 -27
src/view/screens/Storybook/Buttons.tsx
··· 4 4 import {atoms as a} from '#/alf' 5 5 import { 6 6 Button, 7 - ButtonColor, 7 + type ButtonColor, 8 8 ButtonIcon, 9 9 ButtonText, 10 - ButtonVariant, 10 + type ButtonVariant, 11 11 } from '#/components/Button' 12 12 import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' 13 13 import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' ··· 19 19 <H1>Buttons</H1> 20 20 21 21 <View style={[a.flex_row, a.flex_wrap, a.gap_md, a.align_start]}> 22 - {['primary', 'secondary', 'secondary_inverted', 'negative'].map( 23 - color => ( 24 - <View key={color} style={[a.gap_md, a.align_start]}> 25 - {['solid', 'outline', 'ghost'].map(variant => ( 26 - <React.Fragment key={variant}> 27 - <Button 28 - variant={variant as ButtonVariant} 29 - color={color as ButtonColor} 30 - size="large" 31 - label="Click here"> 32 - <ButtonText>Button</ButtonText> 33 - </Button> 34 - <Button 35 - disabled 36 - variant={variant as ButtonVariant} 37 - color={color as ButtonColor} 38 - size="large" 39 - label="Click here"> 40 - <ButtonText>Button</ButtonText> 41 - </Button> 42 - </React.Fragment> 43 - ))} 44 - </View> 45 - ), 46 - )} 22 + {[ 23 + 'primary', 24 + 'secondary', 25 + 'secondary_inverted', 26 + 'negative', 27 + 'negative_secondary', 28 + ].map(color => ( 29 + <View key={color} style={[a.gap_md, a.align_start]}> 30 + {['solid', 'outline', 'ghost'].map(variant => ( 31 + <React.Fragment key={variant}> 32 + <Button 33 + variant={variant as ButtonVariant} 34 + color={color as ButtonColor} 35 + size="large" 36 + label="Click here"> 37 + <ButtonText>Button</ButtonText> 38 + </Button> 39 + <Button 40 + disabled 41 + variant={variant as ButtonVariant} 42 + color={color as ButtonColor} 43 + size="large" 44 + label="Click here"> 45 + <ButtonText>Button</ButtonText> 46 + </Button> 47 + </React.Fragment> 48 + ))} 49 + </View> 50 + ))} 47 51 48 52 <View style={[a.flex_row, a.gap_md, a.align_start]}> 49 53 <View style={[a.gap_md, a.align_start]}>
+1 -2
src/view/screens/Storybook/index.tsx
··· 2 2 import {View} from 'react-native' 3 3 import {useNavigation} from '@react-navigation/native' 4 4 5 - import {NavigationProp} from '#/lib/routes/types' 5 + import {type NavigationProp} from '#/lib/routes/types' 6 6 import {useSetThemePrefs} from '#/state/shell' 7 7 import {ListContained} from '#/view/screens/Storybook/ListContained' 8 8 import {atoms as a, ThemeProvider} from '#/alf' ··· 115 115 <Typography /> 116 116 <Spacing /> 117 117 <Shadows /> 118 - <Buttons /> 119 118 <Icons /> 120 119 <Links /> 121 120 <Dialogs />
+3
src/view/shell/Drawer.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 import {StackActions, useNavigation} from '@react-navigation/native' 7 7 8 + import {useActorStatus} from '#/lib/actor-status' 8 9 import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants' 9 10 import {type PressableScale} from '#/lib/custom-animations/PressableScale' 10 11 import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState' ··· 67 68 const t = useTheme() 68 69 const {data: profile} = useProfileQuery({did: account.did}) 69 70 const verification = useSimpleVerificationState({profile}) 71 + const {isActive: live} = useActorStatus(profile) 70 72 71 73 return ( 72 74 <TouchableOpacity ··· 81 83 // See https://github.com/bluesky-social/social-app/pull/1801: 82 84 usePlainRNImage={true} 83 85 type={profile?.associated?.labeler ? 'labeler' : 'user'} 86 + live={live} 84 87 /> 85 88 <View style={[a.gap_2xs]}> 86 89 <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
+20 -4
src/view/shell/bottom-bar/BottomBar.tsx
··· 7 7 import {type BottomTabBarProps} from '@react-navigation/bottom-tabs' 8 8 import {StackActions} from '@react-navigation/native' 9 9 10 + import {useActorStatus} from '#/lib/actor-status' 10 11 import {PressableScale} from '#/lib/custom-animations/PressableScale' 11 12 import {BOTTOM_BAR_AVI} from '#/lib/demo' 12 13 import {useHaptics} from '#/lib/haptics' ··· 127 128 }, [accountSwitchControl, playHaptic]) 128 129 129 130 const [demoMode] = useDemoMode() 131 + const {isActive: live} = useActorStatus(profile) 130 132 131 133 return ( 132 134 <> ··· 260 262 pal.text, 261 263 styles.profileIcon, 262 264 styles.onProfile, 263 - {borderColor: pal.text.color}, 265 + { 266 + borderColor: pal.text.color, 267 + borderWidth: live ? 0 : 1, 268 + }, 264 269 ]}> 265 270 <UserAvatar 266 271 avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar} 267 - size={iconWidth - 3} 272 + size={iconWidth - 2} 268 273 // See https://github.com/bluesky-social/social-app/pull/1801: 269 274 usePlainRNImage={true} 270 275 type={profile?.associated?.labeler ? 'labeler' : 'user'} 276 + live={live} 277 + hideLiveBadge 271 278 /> 272 279 </View> 273 280 ) : ( 274 281 <View 275 - style={[styles.ctrlIcon, pal.text, styles.profileIcon]}> 282 + style={[ 283 + styles.ctrlIcon, 284 + pal.text, 285 + styles.profileIcon, 286 + { 287 + borderWidth: live ? 0 : 1, 288 + }, 289 + ]}> 276 290 <UserAvatar 277 291 avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar} 278 - size={iconWidth - 3} 292 + size={iconWidth - 2} 279 293 // See https://github.com/bluesky-social/social-app/pull/1801: 280 294 usePlainRNImage={true} 281 295 type={profile?.associated?.labeler ? 'labeler' : 'user'} 296 + live={live} 297 + hideLiveBadge 282 298 /> 283 299 </View> 284 300 )}
+47 -29
src/view/shell/desktop/LeftNav.tsx
··· 9 9 useNavigationState, 10 10 } from '@react-navigation/native' 11 11 12 + import {useActorStatus} from '#/lib/actor-status' 12 13 import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' 13 14 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 14 15 import {usePalette} from '#/lib/hooks/usePalette' ··· 99 100 account, 100 101 profile: profiles?.find(p => p.did === account.did), 101 102 })) 103 + 104 + const {isActive: live} = useActorStatus(profile) 102 105 103 106 return ( 104 107 <View style={[a.my_md, !leftNavMinimal && [a.w_full, a.align_start]]}> ··· 142 145 avatar={profile.avatar} 143 146 size={size} 144 147 type={profile?.associated?.labeler ? 'labeler' : 'user'} 148 + live={live} 145 149 /> 146 150 </View> 147 151 {!leftNavMinimal && ( ··· 226 230 signOutPromptControl: DialogControlProps 227 231 }) { 228 232 const {_} = useLingui() 229 - const {onPressSwitchAccount, pendingDid} = useAccountSwitcher() 230 233 const {setShowLoggedOut} = useLoggedOutViewControls() 231 234 const closeEverything = useCloseAllActiveElements() 232 235 ··· 243 246 <Trans>Switch account</Trans> 244 247 </Menu.LabelText> 245 248 {accounts.map(other => ( 246 - <Menu.Item 247 - disabled={!!pendingDid} 248 - style={[{minWidth: 150}]} 249 + <SwitchMenuItem 249 250 key={other.account.did} 250 - label={_( 251 - msg`Switch to ${sanitizeHandle( 252 - other.profile?.handle ?? other.account.handle, 253 - '@', 254 - )}`, 255 - )} 256 - onPress={() => 257 - onPressSwitchAccount(other.account, 'SwitchAccount') 258 - }> 259 - <View style={[{marginLeft: tokens.space._2xs * -1}]}> 260 - <UserAvatar 261 - avatar={other.profile?.avatar} 262 - size={20} 263 - type={ 264 - other.profile?.associated?.labeler ? 'labeler' : 'user' 265 - } 266 - /> 267 - </View> 268 - <Menu.ItemText> 269 - {sanitizeHandle( 270 - other.profile?.handle ?? other.account.handle, 271 - '@', 272 - )} 273 - </Menu.ItemText> 274 - </Menu.Item> 251 + account={other.account} 252 + profile={other.profile} 253 + /> 275 254 ))} 276 255 </Menu.Group> 277 256 <Menu.Divider /> ··· 292 271 </Menu.ItemText> 293 272 </Menu.Item> 294 273 </Menu.Outer> 274 + ) 275 + } 276 + 277 + function SwitchMenuItem({ 278 + account, 279 + profile, 280 + }: { 281 + account: SessionAccount 282 + profile: AppBskyActorDefs.ProfileViewDetailed | undefined 283 + }) { 284 + const {_} = useLingui() 285 + const {onPressSwitchAccount, pendingDid} = useAccountSwitcher() 286 + const {isActive: live} = useActorStatus(profile) 287 + 288 + return ( 289 + <Menu.Item 290 + disabled={!!pendingDid} 291 + style={[a.gap_sm, {minWidth: 150}]} 292 + key={account.did} 293 + label={_( 294 + msg`Switch to ${sanitizeHandle( 295 + profile?.handle ?? account.handle, 296 + '@', 297 + )}`, 298 + )} 299 + onPress={() => onPressSwitchAccount(account, 'SwitchAccount')}> 300 + <View> 301 + <UserAvatar 302 + avatar={profile?.avatar} 303 + size={20} 304 + type={profile?.associated?.labeler ? 'labeler' : 'user'} 305 + live={live} 306 + hideLiveBadge 307 + /> 308 + </View> 309 + <Menu.ItemText> 310 + {sanitizeHandle(profile?.handle ?? account.handle, '@')} 311 + </Menu.ItemText> 312 + </Menu.Item> 295 313 ) 296 314 } 297 315
+149 -149
yarn.lock
··· 56 56 resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.2.0.tgz#f39098747dabf8a245d0ed6edc50f362aa4d95f8" 57 57 integrity sha512-0bRbAlI8Ayh03wRwncAMEAyUKtZ+AuTS1jgPrfym1WVOAOiottI/ZmgccqLl6w5MbxVcClNQF7WYGKvGwGoIhA== 58 58 59 - "@atproto-labs/xrpc-utils@0.0.13": 60 - version "0.0.13" 61 - resolved "https://registry.yarnpkg.com/@atproto-labs/xrpc-utils/-/xrpc-utils-0.0.13.tgz#5d684bc574537066d3c3404b4d69c03b8672f9a4" 62 - integrity sha512-uQnZhpHFa3EDHct+/slPl5+q2myMBTr6stZbdb6O877wtjEwN4C/7A0eMKaIqETmtxULYpZGapYJ7PG34aa7uQ== 59 + "@atproto-labs/xrpc-utils@0.0.14": 60 + version "0.0.14" 61 + resolved "https://registry.yarnpkg.com/@atproto-labs/xrpc-utils/-/xrpc-utils-0.0.14.tgz#eefe1ccf61a4288708601324496b0106d5ed4ae3" 62 + integrity sha512-/f0Dhzi08w3Oqv38wdwQ5bw238GbxhYIcxg08kVReEMTlkyRDC6H5RuqHf8Ff9J3FKqjKHGdxaOdrPNM1hCgeQ== 63 63 dependencies: 64 - "@atproto/xrpc" "^0.6.12" 65 - "@atproto/xrpc-server" "^0.7.17" 64 + "@atproto/xrpc" "^0.7.0" 65 + "@atproto/xrpc-server" "^0.7.18" 66 66 67 - "@atproto/api@^0.15.5": 68 - version "0.15.5" 69 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.5.tgz#cd7e12fd4d4546a73a8e0ea4e7737f883ac3d2a2" 70 - integrity sha512-GiKOrjSXMm8OSpc+pfjFTBYQGX62jmorECkTx2VZbS6KtFKFY0cRQAI+JnQoOLF/8TvzpaAZB7+it73uIqDM7A== 67 + "@atproto/api@^0.15.6": 68 + version "0.15.6" 69 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.6.tgz#3832f16641d89c687794cea14b4aba05ba5993c8" 70 + integrity sha512-hKwrBf60LcI4BqArWyrhWJWIpjwAWUJpW3PVvNzUB1q2W/ByC0JAuwq/F8tZpCEiiVBzHjHVRx4QNA2TA1cG3g== 71 71 dependencies: 72 - "@atproto/common-web" "^0.4.1" 73 - "@atproto/lexicon" "^0.4.10" 72 + "@atproto/common-web" "^0.4.2" 73 + "@atproto/lexicon" "^0.4.11" 74 74 "@atproto/syntax" "^0.4.0" 75 - "@atproto/xrpc" "^0.6.12" 75 + "@atproto/xrpc" "^0.7.0" 76 76 await-lock "^2.2.2" 77 77 multiformats "^9.9.0" 78 78 tlds "^1.234.0" 79 79 zod "^3.23.8" 80 80 81 - "@atproto/aws@^0.2.20": 82 - version "0.2.20" 83 - resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.20.tgz#2b29c12738c8fb2c94f1e1775762319d859c8232" 84 - integrity sha512-lTVkux1gqJuwub1GnyMqWtCc4OSCNKOG2MR+2QUx+qu0Jicqeda7J/7rs6nem+6ngsO7JWm2pfCc6GEUqTiHLQ== 81 + "@atproto/aws@^0.2.21": 82 + version "0.2.21" 83 + resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.21.tgz#06006a101c8004db11384a19366296cd87468326" 84 + integrity sha512-bosExZ3YdFjOehNBcNWsC2mZBrAVLO8Ut/JquypXSahFeeXZP/9rd9F1VGf+vAmjFEKagHXQCb6CRFfJyN+I7A== 85 85 dependencies: 86 - "@atproto/common" "^0.4.10" 86 + "@atproto/common" "^0.4.11" 87 87 "@atproto/crypto" "^0.4.4" 88 - "@atproto/repo" "^0.8.0" 88 + "@atproto/repo" "^0.8.1" 89 89 "@aws-sdk/client-cloudfront" "^3.261.0" 90 90 "@aws-sdk/client-kms" "^3.196.0" 91 91 "@aws-sdk/client-s3" "^3.224.0" ··· 95 95 multiformats "^9.9.0" 96 96 uint8arrays "3.0.0" 97 97 98 - "@atproto/bsky@^0.0.147": 99 - version "0.0.147" 100 - resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.147.tgz#4a35d80a9659703d1811f3d395e15b3d4d07ad0f" 101 - integrity sha512-sMwzY8qsthlSY8NJRyQaO6dX6p6p9BuzU7pIbw8bhKlFwtdPpjSusIBVDp5XImJGs4Rm2eYqBWQUy3egS8Uytw== 98 + "@atproto/bsky@^0.0.148": 99 + version "0.0.148" 100 + resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.148.tgz#f864631e5a9726d3a40c15b0311f730bc16d6bd9" 101 + integrity sha512-09Lzjz9kCK7kPOlJcVj6KbATtoPQwNeeU5s0J2apZYCQmA7wN2xRb5KMf9wr+wa1KO7FwbXKSunwer96dB6zrQ== 102 102 dependencies: 103 103 "@atproto-labs/fetch-node" "0.1.8" 104 - "@atproto-labs/xrpc-utils" "0.0.13" 105 - "@atproto/api" "^0.15.5" 106 - "@atproto/common" "^0.4.10" 104 + "@atproto-labs/xrpc-utils" "0.0.14" 105 + "@atproto/api" "^0.15.6" 106 + "@atproto/common" "^0.4.11" 107 107 "@atproto/crypto" "^0.4.4" 108 108 "@atproto/did" "^0.1.5" 109 - "@atproto/identity" "^0.4.7" 110 - "@atproto/lexicon" "^0.4.10" 111 - "@atproto/repo" "^0.8.0" 112 - "@atproto/sync" "^0.1.22" 109 + "@atproto/identity" "^0.4.8" 110 + "@atproto/lexicon" "^0.4.11" 111 + "@atproto/repo" "^0.8.1" 112 + "@atproto/sync" "^0.1.23" 113 113 "@atproto/syntax" "^0.4.0" 114 - "@atproto/xrpc-server" "^0.7.17" 114 + "@atproto/xrpc-server" "^0.7.18" 115 115 "@bufbuild/protobuf" "^1.5.0" 116 116 "@connectrpc/connect" "^1.1.4" 117 117 "@connectrpc/connect-express" "^1.1.4" ··· 141 141 uint8arrays "3.0.0" 142 142 undici "^6.19.8" 143 143 144 - "@atproto/bsync@^0.0.18": 145 - version "0.0.18" 146 - resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.18.tgz#83c7f061a057f6878b5f65e1b431237f512de072" 147 - integrity sha512-MslGplA3HN9D+L1Ywj0QPmEmg9QyDWjlGSTpqGA+D+GNZ7kjRnf5/XYdQMyUqzD127EqjrbzgCBY82D/GonWoA== 144 + "@atproto/bsync@^0.0.19": 145 + version "0.0.19" 146 + resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.19.tgz#bab3d5e4e7c1ca8de16d9b5efebc49dde12d7160" 147 + integrity sha512-AF9aWbU0VlpT//lIuYKhNRplTv+99ld58kfHTS8jfXCpiOZwxwneTkB1hzE+slXJ63K8i/GyzsQCyvRHWzGWCQ== 148 148 dependencies: 149 - "@atproto/common" "^0.4.10" 149 + "@atproto/common" "^0.4.11" 150 150 "@atproto/syntax" "^0.4.0" 151 151 "@bufbuild/protobuf" "^1.5.0" 152 152 "@connectrpc/connect" "^1.1.4" ··· 157 157 pino-http "^8.2.1" 158 158 typed-emitter "^2.1.0" 159 159 160 - "@atproto/common-web@^0.4.1": 161 - version "0.4.1" 162 - resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.1.tgz#f31054f689f4f52b06da6ffd727e40ecd67a30b6" 163 - integrity sha512-Ghh+djHYMAUCktLKwr2IuGgtjcwSWGudp+K7+N7KBA9pDDloOXUEY8Agjc5SHSo9B1QIEFkegClU5n+apn2e0w== 160 + "@atproto/common-web@^0.4.2": 161 + version "0.4.2" 162 + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.2.tgz#6e3add6939da93d3dfbc8f87e26dc4f57fad7259" 163 + integrity sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw== 164 164 dependencies: 165 165 graphemer "^1.4.0" 166 166 multiformats "^9.9.0" ··· 187 187 pino "^8.6.1" 188 188 zod "^3.14.2" 189 189 190 - "@atproto/common@^0.4.10": 191 - version "0.4.10" 192 - resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.10.tgz#9dc49364ad856f2833ce24afb5e5c7a07b57f888" 193 - integrity sha512-/Yxnax3XOhf46jYpe8/6O3ORjTNMB4YCaxx3V1f+FKy6meTm3GNrJwo8d1CBs0UiTiheRiNATOV3u0s3C7Ydaw== 190 + "@atproto/common@^0.4.11": 191 + version "0.4.11" 192 + resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.11.tgz#9291b7c26f8b3507e280f7ecbdf1695ab5ea62f6" 193 + integrity sha512-Knv0viYXNMfCdIE7jLUiWJKnnMfEwg+vz2epJQi8WOjqtqCFb3W/3Jn72ZiuovIfpdm13MaOiny6w2NErUQC6g== 194 194 dependencies: 195 - "@atproto/common-web" "^0.4.1" 195 + "@atproto/common-web" "^0.4.2" 196 196 "@ipld/dag-cbor" "^7.0.3" 197 197 cbor-x "^1.5.1" 198 198 iso-datestring-validator "^2.2.2" ··· 219 219 "@noble/hashes" "^1.6.1" 220 220 uint8arrays "3.0.0" 221 221 222 - "@atproto/dev-env@^0.3.128": 223 - version "0.3.128" 224 - resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.128.tgz#0a06dc71be671eaa09464b15c65f2b27b9e60cfd" 225 - integrity sha512-1qxPLQLaAUH6SOQJoje9O7LaZ0dp+P/oS/4OQ3I9hYJAZn78dDrFZ7YIC5n1yMTr1iSjHeXvFA9wt0Czcl/uUA== 222 + "@atproto/dev-env@^0.3.129": 223 + version "0.3.130" 224 + resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.130.tgz#444ad315c00bdcf8bdae036d1e6a56a1808b98c6" 225 + integrity sha512-xRQb+b09lpdG1vGdvMk8Yf/AnO4SDQTjKLyPO+LYYeHuOrKKjJWiBorFC8Lp/rnraoM3AcwMKmW48wdd7cOL9g== 226 226 dependencies: 227 - "@atproto/api" "^0.15.5" 228 - "@atproto/bsky" "^0.0.147" 229 - "@atproto/bsync" "^0.0.18" 230 - "@atproto/common-web" "^0.4.1" 227 + "@atproto/api" "^0.15.6" 228 + "@atproto/bsky" "^0.0.148" 229 + "@atproto/bsync" "^0.0.19" 230 + "@atproto/common-web" "^0.4.2" 231 231 "@atproto/crypto" "^0.4.4" 232 - "@atproto/identity" "^0.4.7" 233 - "@atproto/lexicon" "^0.4.10" 234 - "@atproto/ozone" "^0.1.108" 235 - "@atproto/pds" "^0.4.134" 236 - "@atproto/sync" "^0.1.22" 232 + "@atproto/identity" "^0.4.8" 233 + "@atproto/lexicon" "^0.4.11" 234 + "@atproto/ozone" "^0.1.109" 235 + "@atproto/pds" "^0.4.136" 236 + "@atproto/sync" "^0.1.23" 237 237 "@atproto/syntax" "^0.4.0" 238 - "@atproto/xrpc-server" "^0.7.17" 238 + "@atproto/xrpc-server" "^0.7.18" 239 239 "@did-plc/lib" "^0.0.1" 240 240 "@did-plc/server" "^0.0.1" 241 241 dotenv "^16.0.3" ··· 252 252 dependencies: 253 253 zod "^3.23.8" 254 254 255 - "@atproto/identity@^0.4.7": 256 - version "0.4.7" 257 - resolved "https://registry.yarnpkg.com/@atproto/identity/-/identity-0.4.7.tgz#1f8958e49f6046f3412463269e1fc961c0936ff2" 258 - integrity sha512-A61OT9yc74dEFi1elODt/tzQNSwV3ZGZCY5cRl6NYO9t/0AVdaD+fyt81yh3mRxyI8HeVOecvXl3cPX5knz9rQ== 255 + "@atproto/identity@^0.4.8": 256 + version "0.4.8" 257 + resolved "https://registry.yarnpkg.com/@atproto/identity/-/identity-0.4.8.tgz#28ae9f8fe0e83196c5b6747394e759a330a101d9" 258 + integrity sha512-Z0sLnJ87SeNdAifT+rqpgE1Rc3layMMW25gfWNo4u40RGuRODbdfAZlTwBSU2r+Vk45hU+iE+xeQspfednCEnA== 259 259 dependencies: 260 - "@atproto/common-web" "^0.4.1" 260 + "@atproto/common-web" "^0.4.2" 261 261 "@atproto/crypto" "^0.4.4" 262 262 263 263 "@atproto/jwk-jose@0.1.6": ··· 276 276 multiformats "^9.9.0" 277 277 zod "^3.23.8" 278 278 279 - "@atproto/lexicon@^0.4.10": 280 - version "0.4.10" 281 - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.10.tgz#276790a1bca060a55c80d556ce763eaa81f6e944" 282 - integrity sha512-uDbP20vetBgtXPuxoyRcvOGBt2gNe1dFc9yYKcb6jWmXfseHiGTnIlORJOLBXIT2Pz15Eap4fLxAu6zFAykD5A== 279 + "@atproto/lexicon@^0.4.11": 280 + version "0.4.11" 281 + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.11.tgz#d5d09be1faf1d28d1e57051dab4064101f8b1617" 282 + integrity sha512-btefdnvNz2Ao2I+qbmj0F06HC8IlrM/IBz6qOBS50r0S6uDf5tOO+Mv2tSVdimFkdzyDdLtBI1sV36ONxz2cOw== 283 283 dependencies: 284 - "@atproto/common-web" "^0.4.1" 284 + "@atproto/common-web" "^0.4.2" 285 285 "@atproto/syntax" "^0.4.0" 286 286 iso-datestring-validator "^2.2.2" 287 287 multiformats "^9.9.0" 288 288 zod "^3.23.8" 289 289 290 - "@atproto/oauth-provider-api@0.1.1": 291 - version "0.1.1" 292 - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.1.1.tgz#ccca589757cd652015db58dee645463290eddacc" 293 - integrity sha512-Ry7viVoMHzzyohK0UKX/7gJgkWndCchydzAfVV1lmP+84sw7Foci+rXN/laE5EnpVB8QIUW3GmQ93jecbBiyeg== 290 + "@atproto/oauth-provider-api@0.1.2": 291 + version "0.1.2" 292 + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.1.2.tgz#cdca03af4426f8cf9b09bc9eb57a8604f9513831" 293 + integrity sha512-tNAuMrE6D3696euavxo1+Jh7Re0PPwJstbyY8SrdVPXgKJh/LrbpKUKiPNW/p5KyVfRs2tWeAxy+ReESu6SmXA== 294 294 dependencies: 295 295 "@atproto/jwk" "0.1.5" 296 - "@atproto/oauth-types" "0.2.6" 296 + "@atproto/oauth-types" "0.2.7" 297 297 298 - "@atproto/oauth-provider-frontend@0.1.3": 299 - version "0.1.3" 300 - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.1.3.tgz#3cd724bf3dfae99f2b3b152c96963762ff188b89" 301 - integrity sha512-dN/WRMOmj1Bd32i6diX/J+zZ5bZWX+NbQ0BAMjMANpii2gFrgc/pk/zmutYwUhyQeIl7rLkag+nQC4Jhg1I6BQ== 298 + "@atproto/oauth-provider-frontend@0.1.4": 299 + version "0.1.4" 300 + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.1.4.tgz#240a2e58c29d32fa7d4ea9d142c00c23d2469452" 301 + integrity sha512-TLKL5lTmSieHx7+3RVIx7rIxRPP1SNCwzzdTvYB46yd1XrGHdPU//M6CP5OZ1BvcxF6H4JXIkOSWvFseol+gOw== 302 302 optionalDependencies: 303 - "@atproto/oauth-provider-api" "0.1.1" 303 + "@atproto/oauth-provider-api" "0.1.2" 304 304 305 - "@atproto/oauth-provider-ui@0.1.3": 306 - version "0.1.3" 307 - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.1.3.tgz#dc130c308fe3a422a498abc7284e68a722d17ebb" 308 - integrity sha512-Zxxm9nhGMS1ByvaA79zqalpBU2ub4+3gLPkRVQjp/F8jHOedxrocUAKM61B3KTtDdFGvfzvPZ28pxunwy/Rw4g== 305 + "@atproto/oauth-provider-ui@0.1.4": 306 + version "0.1.4" 307 + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.1.4.tgz#5e092d30afa583fdab54fc78371aecb1cbfa017d" 308 + integrity sha512-GTQnB7OUBFSeXcdRseAGYzKe9UUFB/kGjRcIA8+pO5pCMD7JdXI+WliUhsbdmQ2I+OK78aAlCrmygNWpLtpZgg== 309 309 optionalDependencies: 310 - "@atproto/oauth-provider-api" "0.1.1" 310 + "@atproto/oauth-provider-api" "0.1.2" 311 311 312 - "@atproto/oauth-provider@^0.7.5": 313 - version "0.7.5" 314 - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.7.5.tgz#f645800bc76b51ff000a92d67a83c4313db6316d" 315 - integrity sha512-vBRxX7mUDRVbe2rKzRmC3OfyoqAs8N7OA+MdSD+6b154UFp3+blF5phz5NgtxZzNyKSSeZnXy6xSh27YT8QfRg== 312 + "@atproto/oauth-provider@^0.7.6": 313 + version "0.7.6" 314 + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.7.6.tgz#68bc37303611d548bae9f653d41bc89bd8890152" 315 + integrity sha512-4YcnddACznmpuRmHlt9G+kccdv2Gct5qQOF9Yyjse8cl2Td+Rg1gkchpRdWUnyr9fgZzmCsSBYzEfVXge3eUiQ== 316 316 dependencies: 317 317 "@atproto-labs/fetch" "0.2.2" 318 318 "@atproto-labs/fetch-node" "0.1.8" 319 319 "@atproto-labs/pipe" "0.1.0" 320 320 "@atproto-labs/simple-store" "0.2.0" 321 321 "@atproto-labs/simple-store-memory" "0.1.3" 322 - "@atproto/common" "^0.4.10" 322 + "@atproto/common" "^0.4.11" 323 323 "@atproto/jwk" "0.1.5" 324 324 "@atproto/jwk-jose" "0.1.6" 325 - "@atproto/oauth-provider-api" "0.1.1" 326 - "@atproto/oauth-provider-frontend" "0.1.3" 327 - "@atproto/oauth-provider-ui" "0.1.3" 328 - "@atproto/oauth-types" "0.2.6" 325 + "@atproto/oauth-provider-api" "0.1.2" 326 + "@atproto/oauth-provider-frontend" "0.1.4" 327 + "@atproto/oauth-provider-ui" "0.1.4" 328 + "@atproto/oauth-types" "0.2.7" 329 329 "@atproto/syntax" "0.4.0" 330 330 "@hapi/accept" "^6.0.3" 331 331 "@hapi/address" "^5.1.1" ··· 340 340 psl "^1.9.0" 341 341 zod "^3.23.8" 342 342 343 - "@atproto/oauth-types@0.2.6": 344 - version "0.2.6" 345 - resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.2.6.tgz#3cea27b72a6ee274864bd5c791b0c7f369954b03" 346 - integrity sha512-6rUmV7T1YKCgVYLLjm+FGv+dYC8S0+0AHji/azVGDEhTsiadSrlC0H9Pgxix1y89zI1FIf0piBqecBcPewdrJg== 343 + "@atproto/oauth-types@0.2.7": 344 + version "0.2.7" 345 + resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.2.7.tgz#c210868052f8babd98510c19816e3d9a156b33c7" 346 + integrity sha512-2SlDveiSI0oowC+sfuNd/npV8jw/FhokSS26qyUyldTg1g9ZlhxXUfMP4IZOPeZcVn9EszzQRHs1H9ZJqVQIew== 347 347 dependencies: 348 348 "@atproto/jwk" "0.1.5" 349 349 zod "^3.23.8" 350 350 351 - "@atproto/ozone@^0.1.108": 352 - version "0.1.108" 353 - resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.108.tgz#cac673301cebd159a13a8a72281bc917806f3694" 354 - integrity sha512-AuMH/PYmtro8cVnseESRooAerz2EIPwnS4HZgBsSebwBf4HLEGE93yeX26jLAfr57XYgVyYChkXr71LX4t9Erg== 351 + "@atproto/ozone@^0.1.109": 352 + version "0.1.109" 353 + resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.109.tgz#538de28cb21c10afa3fbce0140cd695ef7948e09" 354 + integrity sha512-KokZtu5mhYJdNmYqkI2JZ2hiehxXpi8bbULyWE3f0RKbQRBUBGDVBSF8WkuJUuLzaquyYJVtg3MZFp9ELBcg0g== 355 355 dependencies: 356 - "@atproto/api" "^0.15.5" 357 - "@atproto/common" "^0.4.10" 356 + "@atproto/api" "^0.15.6" 357 + "@atproto/common" "^0.4.11" 358 358 "@atproto/crypto" "^0.4.4" 359 - "@atproto/identity" "^0.4.7" 360 - "@atproto/lexicon" "^0.4.10" 359 + "@atproto/identity" "^0.4.8" 360 + "@atproto/lexicon" "^0.4.11" 361 361 "@atproto/syntax" "^0.4.0" 362 - "@atproto/xrpc" "^0.6.12" 363 - "@atproto/xrpc-server" "^0.7.17" 362 + "@atproto/xrpc" "^0.7.0" 363 + "@atproto/xrpc-server" "^0.7.18" 364 364 "@did-plc/lib" "^0.0.1" 365 365 compression "^1.7.4" 366 366 cors "^2.8.5" ··· 378 378 undici "^6.14.1" 379 379 ws "^8.12.0" 380 380 381 - "@atproto/pds@^0.4.134": 382 - version "0.4.134" 383 - resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.134.tgz#0b9065f4ab6493cce4df7faf1167d2689b12c8b3" 384 - integrity sha512-dS/OOspAv7L9kWqXVizCF64Af87DWF8bzu1QrclqaI29MTTnaaK/PPKd/kAELhBPBi5CVPirvySSqLj0W4Q93A== 381 + "@atproto/pds@^0.4.136": 382 + version "0.4.136" 383 + resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.136.tgz#53989ff7784c4d1e68d745d69721e71ba82a111d" 384 + integrity sha512-sao4iq/CRWwdM0gljw7XGg/ef4OTWFc6RU2g0nNgJLvxfPO3uMG8Ze1S6tfhr9wvhIKZWVCzzPruTglrlWMEYw== 385 385 dependencies: 386 386 "@atproto-labs/fetch-node" "0.1.8" 387 - "@atproto-labs/xrpc-utils" "0.0.13" 388 - "@atproto/api" "^0.15.5" 389 - "@atproto/aws" "^0.2.20" 390 - "@atproto/common" "^0.4.10" 387 + "@atproto-labs/xrpc-utils" "0.0.14" 388 + "@atproto/api" "^0.15.6" 389 + "@atproto/aws" "^0.2.21" 390 + "@atproto/common" "^0.4.11" 391 391 "@atproto/crypto" "^0.4.4" 392 - "@atproto/identity" "^0.4.7" 393 - "@atproto/lexicon" "^0.4.10" 394 - "@atproto/oauth-provider" "^0.7.5" 395 - "@atproto/repo" "^0.8.0" 392 + "@atproto/identity" "^0.4.8" 393 + "@atproto/lexicon" "^0.4.11" 394 + "@atproto/oauth-provider" "^0.7.6" 395 + "@atproto/repo" "^0.8.1" 396 396 "@atproto/syntax" "^0.4.0" 397 - "@atproto/xrpc" "^0.6.12" 398 - "@atproto/xrpc-server" "^0.7.17" 397 + "@atproto/xrpc" "^0.7.0" 398 + "@atproto/xrpc-server" "^0.7.18" 399 399 "@did-plc/lib" "^0.0.4" 400 400 "@hapi/address" "^5.1.1" 401 401 better-sqlite3 "^10.0.0" ··· 425 425 undici "^6.19.8" 426 426 zod "^3.23.8" 427 427 428 - "@atproto/repo@^0.8.0": 429 - version "0.8.0" 430 - resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.0.tgz#14261421e2c5fe95b6a8af0b1296fca50b216618" 431 - integrity sha512-Er4Mpd8XWPwVLcUlKFxUpnyBC+J+oBxARoUGXMTLjdQyg0FmWtZzeYnse8FV/L36DeWV0+v/tqYYggJcOOe1HA== 428 + "@atproto/repo@^0.8.1": 429 + version "0.8.1" 430 + resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.1.tgz#be8c6b93c000944b81aaa1026d6c50d82c025d74" 431 + integrity sha512-d1NtHhXYJVJlFVI6mbVOUnpB0rnhqxPnZcALkJoYJjaDPVr4NNqRFAtrwb+GHzxT6DhijoXYQf24pKGfEFDd4g== 432 432 dependencies: 433 - "@atproto/common" "^0.4.10" 434 - "@atproto/common-web" "^0.4.1" 433 + "@atproto/common" "^0.4.11" 434 + "@atproto/common-web" "^0.4.2" 435 435 "@atproto/crypto" "^0.4.4" 436 - "@atproto/lexicon" "^0.4.10" 436 + "@atproto/lexicon" "^0.4.11" 437 437 "@ipld/dag-cbor" "^7.0.0" 438 438 multiformats "^9.9.0" 439 439 uint8arrays "3.0.0" 440 440 varint "^6.0.0" 441 441 zod "^3.23.8" 442 442 443 - "@atproto/sync@^0.1.22": 444 - version "0.1.22" 445 - resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.22.tgz#3ba82a3c5f76cee372adc00ba162b65eaf7dadf9" 446 - integrity sha512-FdBHDkNRqWmfUUG52EhkeHH13LdaZ6V5z8978fGMFxXPsTXDE6RSr/vNRt4FUDS5j7+9csKgNV3AyIFUmSYmOg== 443 + "@atproto/sync@^0.1.23": 444 + version "0.1.23" 445 + resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.23.tgz#01d4ecf9d5ddc624d14e8fb98927f0b2a97eafeb" 446 + integrity sha512-1ItRNHMLMcBeTziOZpxS4Q+ha2enQce3fSiAQaCpLCQ8VTNq1D1aRR6ePZCQFzab9jDDtBz0v4FufOnMByRIeg== 447 447 dependencies: 448 - "@atproto/common" "^0.4.10" 449 - "@atproto/identity" "^0.4.7" 450 - "@atproto/lexicon" "^0.4.10" 451 - "@atproto/repo" "^0.8.0" 448 + "@atproto/common" "^0.4.11" 449 + "@atproto/identity" "^0.4.8" 450 + "@atproto/lexicon" "^0.4.11" 451 + "@atproto/repo" "^0.8.1" 452 452 "@atproto/syntax" "^0.4.0" 453 - "@atproto/xrpc-server" "^0.7.17" 453 + "@atproto/xrpc-server" "^0.7.18" 454 454 multiformats "^9.9.0" 455 455 p-queue "^6.6.2" 456 456 ws "^8.12.0" ··· 460 460 resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.0.tgz#bec71552087bb24c208a06ef418c0040b65542f2" 461 461 integrity sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA== 462 462 463 - "@atproto/xrpc-server@^0.7.17": 464 - version "0.7.17" 465 - resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.7.17.tgz#39083754dbefd93a89a02e8f64334cb079a90c69" 466 - integrity sha512-il32raoUc/5eqKMtlHMb+ndCx2nx0Fecjd8Fqw6KNTeS6HB6MYSZvIg3blwV/KdUehmOS6rMy6YrgtFK6GbSQQ== 463 + "@atproto/xrpc-server@^0.7.18": 464 + version "0.7.18" 465 + resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.7.18.tgz#7cb6e517da2afec1c9bee70d92c07667a80718ec" 466 + integrity sha512-kjlAsI+UNbbm6AK3Y5Hb4BJ7VQHNKiYYu2kX5vhZJZHO8qfO40GPYYb/2TknZV8IG6fDPBQhUpcDRolI86sgag== 467 467 dependencies: 468 - "@atproto/common" "^0.4.10" 468 + "@atproto/common" "^0.4.11" 469 469 "@atproto/crypto" "^0.4.4" 470 - "@atproto/lexicon" "^0.4.10" 471 - "@atproto/xrpc" "^0.6.12" 470 + "@atproto/lexicon" "^0.4.11" 471 + "@atproto/xrpc" "^0.7.0" 472 472 cbor-x "^1.5.1" 473 473 express "^4.17.2" 474 474 http-errors "^2.0.0" ··· 478 478 ws "^8.12.0" 479 479 zod "^3.23.8" 480 480 481 - "@atproto/xrpc@^0.6.12": 482 - version "0.6.12" 483 - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.12.tgz#a21ee5b87fde63994c98c34098d5e092252e25d0" 484 - integrity sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w== 481 + "@atproto/xrpc@^0.7.0": 482 + version "0.7.0" 483 + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.0.tgz#7d1e497d682431fecd7085d7482e83d8a33821b0" 484 + integrity sha512-SfhP9dGx2qclaScFDb58Jnrmim5nk4geZXCqg6sB0I/KZhZEkr9iIx1hLCp+sxkIfEsmEJjeWO4B0rjUIJW5cw== 485 485 dependencies: 486 - "@atproto/lexicon" "^0.4.10" 486 + "@atproto/lexicon" "^0.4.11" 487 487 zod "^3.23.8" 488 488 489 489 "@aws-crypto/crc32@3.0.0":