Bluesky app fork with some witchin' additions 💫

[APP-1767] Live event feeds (#9696)

* Update env config for new worker setup

* Add liveEvents context

* Add live event feed card to Explore

* Add Discover banner/trending handling, device storage

* Add prefs methods, hook up to Discover banner

* Add settings toggle

* Fix up metrics and mutations

* Simplify undo logic

* Handle previews

* Update overlay color and text color handling

* Update metrics

* Handle live event in feed card

* Show multiple feeds on Explore for bsky team

* Fix dev/preview handling bug

* Add sidebar cards, update types

* Fix type error

* Fix type error

* Feedback

* Blurhash fix

* Copy update

* Dev only debug hook

authored by

Eric Bailey and committed by
GitHub
5922c662 e684c215

+1162 -135
+5 -2
.env.example
··· 34 # Bitdrift API key. If undefined, Bitdrift will be disabled. 35 EXPO_PUBLIC_BITDRIFT_API_KEY= 36 37 - # bapp-config web worker URL 38 - BAPP_CONFIG_DEV_URL=
··· 34 # Bitdrift API key. If undefined, Bitdrift will be disabled. 35 EXPO_PUBLIC_BITDRIFT_API_KEY= 36 37 + # geolocation web worker URL 38 + GEOLOCATION_DEV_URL= 39 + 40 + # live-events web worker URL 41 + LIVE_EVENTS_DEV_URL=
+1
.eslintrc.js
··· 35 'Admonition', 36 'Admonition.Admonition', 37 'Toast.Action', 38 'AgeAssuranceAdmonition', 39 'Span', 40 'StackedButton',
··· 35 'Admonition', 36 'Admonition.Admonition', 37 'Toast.Action', 38 + 'toast.Action', 39 'AgeAssuranceAdmonition', 40 'Span', 41 'StackedButton',
+1 -1
package.json
··· 73 "icons:optimize": "svgo -f ./assets/icons" 74 }, 75 "dependencies": { 76 - "@atproto/api": "^0.18.13", 77 "@bitdrift/react-native": "^0.6.8", 78 "@braintree/sanitize-url": "^6.0.2", 79 "@bsky.app/alf": "^0.1.6",
··· 73 "icons:optimize": "svgo -f ./assets/icons" 74 }, 75 "dependencies": { 76 + "@atproto/api": "^0.18.15", 77 "@bitdrift/react-native": "^0.6.8", 78 "@braintree/sanitize-url": "^6.0.2", 79 "@bsky.app/alf": "^0.1.6",
+50 -43
src/App.native.tsx
··· 69 import {ToastOutlet} from '#/components/Toast' 70 import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance' 71 import {prefetchAgeAssuranceConfig} from '#/ageAssurance' 72 import * as Geo from '#/geolocation' 73 import {Splash} from '#/Splash' 74 import {BottomSheetProvider} from '../modules/bottom-sheet' ··· 92 */ 93 Geo.resolve() 94 prefetchAgeAssuranceConfig() 95 96 function InnerApp() { 97 const [isReady, setIsReady] = React.useState(false) ··· 141 <QueryProvider currentDid={currentAccount?.did}> 142 <PolicyUpdateOverlayProvider> 143 <StatsigProvider> 144 - <AgeAssuranceV2Provider> 145 - <ComposerProvider> 146 - <MessagesProvider> 147 - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 148 - <LabelDefsProvider> 149 - <ModerationOptsProvider> 150 - <LoggedOutViewProvider> 151 - <SelectedFeedProvider> 152 - <HiddenRepliesProvider> 153 - <HomeBadgeProvider> 154 - <UnreadNotifsProvider> 155 - <BackgroundNotificationPreferencesProvider> 156 - <MutedThreadsProvider> 157 - <ProgressGuideProvider> 158 - <ServiceAccountManager> 159 - <EmailVerificationProvider> 160 - <HideBottomBarBorderProvider> 161 - <GestureHandlerRootView 162 - style={s.h100pct}> 163 - <GlobalGestureEventsProvider> 164 - <IntentDialogProvider> 165 - <TestCtrls /> 166 - <Shell /> 167 - <ToastOutlet /> 168 - </IntentDialogProvider> 169 - </GlobalGestureEventsProvider> 170 - </GestureHandlerRootView> 171 - </HideBottomBarBorderProvider> 172 - </EmailVerificationProvider> 173 - </ServiceAccountManager> 174 - </ProgressGuideProvider> 175 - </MutedThreadsProvider> 176 - </BackgroundNotificationPreferencesProvider> 177 - </UnreadNotifsProvider> 178 - </HomeBadgeProvider> 179 - </HiddenRepliesProvider> 180 - </SelectedFeedProvider> 181 - </LoggedOutViewProvider> 182 - </ModerationOptsProvider> 183 - </LabelDefsProvider> 184 - </MessagesProvider> 185 - </ComposerProvider> 186 - </AgeAssuranceV2Provider> 187 </StatsigProvider> 188 </PolicyUpdateOverlayProvider> 189 </QueryProvider>
··· 69 import {ToastOutlet} from '#/components/Toast' 70 import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance' 71 import {prefetchAgeAssuranceConfig} from '#/ageAssurance' 72 + import { 73 + prefetchLiveEvents, 74 + Provider as LiveEventsProvider, 75 + } from '#/features/liveEvents/context' 76 import * as Geo from '#/geolocation' 77 import {Splash} from '#/Splash' 78 import {BottomSheetProvider} from '../modules/bottom-sheet' ··· 96 */ 97 Geo.resolve() 98 prefetchAgeAssuranceConfig() 99 + prefetchLiveEvents() 100 101 function InnerApp() { 102 const [isReady, setIsReady] = React.useState(false) ··· 146 <QueryProvider currentDid={currentAccount?.did}> 147 <PolicyUpdateOverlayProvider> 148 <StatsigProvider> 149 + <LiveEventsProvider> 150 + <AgeAssuranceV2Provider> 151 + <ComposerProvider> 152 + <MessagesProvider> 153 + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 154 + <LabelDefsProvider> 155 + <ModerationOptsProvider> 156 + <LoggedOutViewProvider> 157 + <SelectedFeedProvider> 158 + <HiddenRepliesProvider> 159 + <HomeBadgeProvider> 160 + <UnreadNotifsProvider> 161 + <BackgroundNotificationPreferencesProvider> 162 + <MutedThreadsProvider> 163 + <ProgressGuideProvider> 164 + <ServiceAccountManager> 165 + <EmailVerificationProvider> 166 + <HideBottomBarBorderProvider> 167 + <GestureHandlerRootView 168 + style={s.h100pct}> 169 + <GlobalGestureEventsProvider> 170 + <IntentDialogProvider> 171 + <TestCtrls /> 172 + <Shell /> 173 + <ToastOutlet /> 174 + </IntentDialogProvider> 175 + </GlobalGestureEventsProvider> 176 + </GestureHandlerRootView> 177 + </HideBottomBarBorderProvider> 178 + </EmailVerificationProvider> 179 + </ServiceAccountManager> 180 + </ProgressGuideProvider> 181 + </MutedThreadsProvider> 182 + </BackgroundNotificationPreferencesProvider> 183 + </UnreadNotifsProvider> 184 + </HomeBadgeProvider> 185 + </HiddenRepliesProvider> 186 + </SelectedFeedProvider> 187 + </LoggedOutViewProvider> 188 + </ModerationOptsProvider> 189 + </LabelDefsProvider> 190 + </MessagesProvider> 191 + </ComposerProvider> 192 + </AgeAssuranceV2Provider> 193 + </LiveEventsProvider> 194 </StatsigProvider> 195 </PolicyUpdateOverlayProvider> 196 </QueryProvider>
+46 -39
src/App.web.tsx
··· 57 import {ToastOutlet} from '#/components/Toast' 58 import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance' 59 import {prefetchAgeAssuranceConfig} from '#/ageAssurance' 60 import * as Geo from '#/geolocation' 61 import {Splash} from '#/Splash' 62 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' ··· 67 */ 68 Geo.resolve() 69 prefetchAgeAssuranceConfig() 70 71 function InnerApp() { 72 const [isReady, setIsReady] = React.useState(false) ··· 117 <QueryProvider currentDid={currentAccount?.did}> 118 <PolicyUpdateOverlayProvider> 119 <StatsigProvider> 120 - <AgeAssuranceV2Provider> 121 - <ComposerProvider> 122 - <MessagesProvider> 123 - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 124 - <LabelDefsProvider> 125 - <ModerationOptsProvider> 126 - <LoggedOutViewProvider> 127 - <SelectedFeedProvider> 128 - <HiddenRepliesProvider> 129 - <HomeBadgeProvider> 130 - <UnreadNotifsProvider> 131 - <BackgroundNotificationPreferencesProvider> 132 - <MutedThreadsProvider> 133 - <SafeAreaProvider> 134 - <ProgressGuideProvider> 135 - <ServiceConfigProvider> 136 - <EmailVerificationProvider> 137 - <HideBottomBarBorderProvider> 138 - <IntentDialogProvider> 139 - <Shell /> 140 - <ToastOutlet /> 141 - </IntentDialogProvider> 142 - </HideBottomBarBorderProvider> 143 - </EmailVerificationProvider> 144 - </ServiceConfigProvider> 145 - </ProgressGuideProvider> 146 - </SafeAreaProvider> 147 - </MutedThreadsProvider> 148 - </BackgroundNotificationPreferencesProvider> 149 - </UnreadNotifsProvider> 150 - </HomeBadgeProvider> 151 - </HiddenRepliesProvider> 152 - </SelectedFeedProvider> 153 - </LoggedOutViewProvider> 154 - </ModerationOptsProvider> 155 - </LabelDefsProvider> 156 - </MessagesProvider> 157 - </ComposerProvider> 158 - </AgeAssuranceV2Provider> 159 </StatsigProvider> 160 </PolicyUpdateOverlayProvider> 161 </QueryProvider>
··· 57 import {ToastOutlet} from '#/components/Toast' 58 import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance' 59 import {prefetchAgeAssuranceConfig} from '#/ageAssurance' 60 + import { 61 + prefetchLiveEvents, 62 + Provider as LiveEventsProvider, 63 + } from '#/features/liveEvents/context' 64 import * as Geo from '#/geolocation' 65 import {Splash} from '#/Splash' 66 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' ··· 71 */ 72 Geo.resolve() 73 prefetchAgeAssuranceConfig() 74 + prefetchLiveEvents() 75 76 function InnerApp() { 77 const [isReady, setIsReady] = React.useState(false) ··· 122 <QueryProvider currentDid={currentAccount?.did}> 123 <PolicyUpdateOverlayProvider> 124 <StatsigProvider> 125 + <LiveEventsProvider> 126 + <AgeAssuranceV2Provider> 127 + <ComposerProvider> 128 + <MessagesProvider> 129 + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 130 + <LabelDefsProvider> 131 + <ModerationOptsProvider> 132 + <LoggedOutViewProvider> 133 + <SelectedFeedProvider> 134 + <HiddenRepliesProvider> 135 + <HomeBadgeProvider> 136 + <UnreadNotifsProvider> 137 + <BackgroundNotificationPreferencesProvider> 138 + <MutedThreadsProvider> 139 + <SafeAreaProvider> 140 + <ProgressGuideProvider> 141 + <ServiceConfigProvider> 142 + <EmailVerificationProvider> 143 + <HideBottomBarBorderProvider> 144 + <IntentDialogProvider> 145 + <Shell /> 146 + <ToastOutlet /> 147 + </IntentDialogProvider> 148 + </HideBottomBarBorderProvider> 149 + </EmailVerificationProvider> 150 + </ServiceConfigProvider> 151 + </ProgressGuideProvider> 152 + </SafeAreaProvider> 153 + </MutedThreadsProvider> 154 + </BackgroundNotificationPreferencesProvider> 155 + </UnreadNotifsProvider> 156 + </HomeBadgeProvider> 157 + </HiddenRepliesProvider> 158 + </SelectedFeedProvider> 159 + </LoggedOutViewProvider> 160 + </ModerationOptsProvider> 161 + </LabelDefsProvider> 162 + </MessagesProvider> 163 + </ComposerProvider> 164 + </AgeAssuranceV2Provider> 165 + </LiveEventsProvider> 166 </StatsigProvider> 167 </PolicyUpdateOverlayProvider> 168 </QueryProvider>
+6 -1
src/alf/index.tsx
··· 11 import {themes} from '#/alf/themes' 12 import {type Device} from '#/storage' 13 14 - export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf' 15 export {atoms} from '#/alf/atoms' 16 export * from '#/alf/breakpoints' 17 export * from '#/alf/fonts'
··· 11 import {themes} from '#/alf/themes' 12 import {type Device} from '#/storage' 13 14 + export { 15 + type TextStyleProp, 16 + type Theme, 17 + utils, 18 + type ViewStyleProp, 19 + } from '@bsky.app/alf' 20 export {atoms} from '#/alf/atoms' 21 export * from '#/alf/breakpoints' 22 export * from '#/alf/fonts'
+35 -3
src/components/FeedCard.tsx
··· 1 - import React from 'react' 2 import {type GestureResponderEvent, View} from 'react-native' 3 import { 4 type AppBskyFeedDefs, ··· 21 import {useSession} from '#/state/session' 22 import * as Toast from '#/view/com/util/Toast' 23 import {UserAvatar} from '#/view/com/util/UserAvatar' 24 - import {atoms as a, useTheme} from '#/alf' 25 import { 26 Button, 27 ButtonIcon, 28 type ButtonProps, 29 ButtonText, 30 } from '#/components/Button' 31 import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' 32 import {Link as InternalLink, type LinkProps} from '#/components/Link' 33 import {Loader} from '#/components/Loader' 34 import * as Prompt from '#/components/Prompt' 35 import {RichText, type RichTextProps} from '#/components/RichText' 36 import {Text} from '#/components/Typography' 37 import type * as bsky from '#/types/bsky' 38 import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash' 39 ··· 49 <Outer> 50 <Header> 51 <Avatar src={view.avatar} /> 52 - <TitleAndByline title={view.displayName} creator={view.creator} /> 53 <SaveButton view={view} pin /> 54 </Header> 55 <Description description={view.description} /> ··· 118 export function TitleAndByline({ 119 title, 120 creator, 121 }: { 122 title: string 123 creator?: bsky.profile.AnyProfileView 124 }) { 125 const t = useTheme() 126 127 return ( 128 <View style={[a.flex_1]}> 129 <Text 130 emoji 131 style={[a.text_md, a.font_semi_bold, a.leading_snug]}
··· 1 + import React, {useMemo} from 'react' 2 import {type GestureResponderEvent, View} from 'react-native' 3 import { 4 type AppBskyFeedDefs, ··· 21 import {useSession} from '#/state/session' 22 import * as Toast from '#/view/com/util/Toast' 23 import {UserAvatar} from '#/view/com/util/UserAvatar' 24 + import {atoms as a, select, useTheme} from '#/alf' 25 import { 26 Button, 27 ButtonIcon, 28 type ButtonProps, 29 ButtonText, 30 } from '#/components/Button' 31 + import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' 32 import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' 33 import {Link as InternalLink, type LinkProps} from '#/components/Link' 34 import {Loader} from '#/components/Loader' 35 import * as Prompt from '#/components/Prompt' 36 import {RichText, type RichTextProps} from '#/components/RichText' 37 import {Text} from '#/components/Typography' 38 + import {useActiveLiveEventFeedUris} from '#/features/liveEvents/context' 39 import type * as bsky from '#/types/bsky' 40 import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash' 41 ··· 51 <Outer> 52 <Header> 53 <Avatar src={view.avatar} /> 54 + <TitleAndByline 55 + title={view.displayName} 56 + creator={view.creator} 57 + uri={view.uri} 58 + /> 59 <SaveButton view={view} pin /> 60 </Header> 61 <Description description={view.description} /> ··· 124 export function TitleAndByline({ 125 title, 126 creator, 127 + uri, 128 }: { 129 title: string 130 creator?: bsky.profile.AnyProfileView 131 + uri?: string 132 }) { 133 const t = useTheme() 134 + const activeLiveEvents = useActiveLiveEventFeedUris() 135 + const liveColor = useMemo( 136 + () => 137 + select(t.name, { 138 + dark: t.palette.negative_600, 139 + dim: t.palette.negative_600, 140 + light: t.palette.negative_500, 141 + }), 142 + [t], 143 + ) 144 145 return ( 146 <View style={[a.flex_1]}> 147 + {uri && activeLiveEvents.has(uri) && ( 148 + <View style={[a.flex_row, a.align_center, a.gap_2xs]}> 149 + <LiveIcon size="xs" fill={liveColor} /> 150 + <Text 151 + style={[ 152 + a.text_2xs, 153 + a.font_medium, 154 + a.leading_snug, 155 + {color: liveColor}, 156 + ]}> 157 + <Trans>Happening now</Trans> 158 + </Text> 159 + </View> 160 + )} 161 <Text 162 emoji 163 style={[a.text_md, a.font_semi_bold, a.leading_snug]}
+1
src/components/FeedInterstitials.tsx
··· 842 <FeedCard.TitleAndByline 843 title={feed.displayName} 844 creator={feed.creator} 845 /> 846 </FeedCard.Header> 847 <FeedCard.Description
··· 842 <FeedCard.TitleAndByline 843 title={feed.displayName} 844 creator={feed.creator} 845 + uri={feed.uri} 846 /> 847 </FeedCard.Header> 848 <FeedCard.Description
+3 -3
src/components/Post/Embed/FeedEmbed.tsx
··· 17 return ( 18 <FeedCard.Link 19 view={embed.view} 20 - style={[a.border, t.atoms.border_contrast_low, a.p_md, a.rounded_sm]}> 21 <FeedCard.Outer> 22 <FeedCard.Header> 23 - <FeedCard.Avatar src={embed.view.avatar} /> 24 <FeedCard.TitleAndByline 25 title={embed.view.displayName} 26 creator={embed.view.creator} 27 /> 28 </FeedCard.Header> 29 - <FeedCard.Likes count={embed.view.likeCount || 0} /> 30 </FeedCard.Outer> 31 </FeedCard.Link> 32 )
··· 17 return ( 18 <FeedCard.Link 19 view={embed.view} 20 + style={[a.border, t.atoms.border_contrast_low, a.p_sm, a.rounded_md]}> 21 <FeedCard.Outer> 22 <FeedCard.Header> 23 + <FeedCard.Avatar src={embed.view.avatar} size={48} /> 24 <FeedCard.TitleAndByline 25 title={embed.view.displayName} 26 creator={embed.view.creator} 27 + uri={embed.view.uri} 28 /> 29 </FeedCard.Header> 30 </FeedCard.Outer> 31 </FeedCard.Link> 32 )
+1 -1
src/components/interstitials/Trending.tsx
··· 41 }, [setTrendingDisabled]) 42 43 return error || noTopics ? null : ( 44 - <View style={[t.atoms.border_contrast_low, a.border_t]}> 45 <BlockDrawerGesture> 46 <ScrollView 47 horizontal
··· 41 }, [setTrendingDisabled]) 42 43 return error || noTopics ? null : ( 44 + <View style={[t.atoms.border_contrast_low, a.border_t, a.border_b]}> 45 <BlockDrawerGesture> 46 <ScrollView 47 horizontal
+15 -5
src/env/common.ts
··· 108 * URLs for the app config web worker. Can be a 109 * locally running server, see `env.example` for more. 110 */ 111 - export const BAPP_CONFIG_DEV_URL = process.env.BAPP_CONFIG_DEV_URL 112 - export const BAPP_CONFIG_PROD_URL = `https://ip.bsky.app` 113 - export const BAPP_CONFIG_URL = IS_DEV 114 - ? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_PROD_URL) 115 - : BAPP_CONFIG_PROD_URL
··· 108 * URLs for the app config web worker. Can be a 109 * locally running server, see `env.example` for more. 110 */ 111 + export const GEOLOCATION_DEV_URL = process.env.GEOLOCATION_DEV_URL 112 + export const GEOLOCATION_PROD_URL = `https://ip.bsky.app` 113 + export const GEOLOCATION_URL = IS_DEV 114 + ? (GEOLOCATION_DEV_URL ?? GEOLOCATION_PROD_URL) 115 + : GEOLOCATION_PROD_URL 116 + 117 + /** 118 + * URLs for the live-event config web worker. Can be a 119 + * locally running server, see `env.example` for more. 120 + */ 121 + export const LIVE_EVENTS_DEV_URL = process.env.LIVE_EVENTS_DEV_URL 122 + export const LIVE_EVENTS_PROD_URL = `https://live-events.workers.bsky.app` 123 + export const LIVE_EVENTS_URL = IS_DEV 124 + ? (LIVE_EVENTS_DEV_URL ?? LIVE_EVENTS_PROD_URL) 125 + : LIVE_EVENTS_PROD_URL
+89
src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx
···
··· 1 + import {View} from 'react-native' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {useTrendingSettings} from '#/state/preferences/trending' 6 + import {atoms as a, useLayoutBreakpoints} from '#/alf' 7 + import {Button} from '#/components/Button' 8 + import {DotGrid_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' 9 + import {TrendingInterstitial} from '#/components/interstitials/Trending' 10 + import {LiveEventFeedCardWide} from '#/features/liveEvents/components/LiveEventFeedCardWide' 11 + import { 12 + LiveEventFeedOptionsMenu, 13 + useDialogControl, 14 + } from '#/features/liveEvents/components/LiveEventFeedOptionsMenu' 15 + import {useUserPreferencedLiveEvents} from '#/features/liveEvents/context' 16 + import {type LiveEventFeed} from '#/features/liveEvents/types' 17 + 18 + export function DiscoverFeedLiveEventFeedsAndTrendingBanner() { 19 + const events = useUserPreferencedLiveEvents() 20 + const {rightNavVisible} = useLayoutBreakpoints() 21 + const {trendingDisabled} = useTrendingSettings() 22 + 23 + if (!events.feeds.length) { 24 + if (!rightNavVisible && !trendingDisabled) { 25 + // only show trending on mobile when live event banner is not shown 26 + return <TrendingInterstitial /> 27 + } else { 28 + // no feed, no trending 29 + return null 30 + } 31 + } 32 + 33 + // On desktop, we show in the sidebar 34 + if (rightNavVisible) return null 35 + 36 + return events.feeds.map(feed => <Inner feed={feed} key={feed.id} />) 37 + } 38 + 39 + function Inner({feed}: {feed: LiveEventFeed}) { 40 + const {_} = useLingui() 41 + const optionsMenuControl = useDialogControl() 42 + const layout = feed.layouts.wide 43 + 44 + return ( 45 + <> 46 + <View style={[a.px_lg, a.pt_md, a.pb_xs]}> 47 + <View> 48 + <LiveEventFeedCardWide feed={feed} metricContext="discover" /> 49 + 50 + <Button 51 + label={_(msg`Configure live event banner`)} 52 + size="tiny" 53 + shape="round" 54 + style={[a.absolute, a.z_10, {top: 6, right: 6}]} 55 + onPress={() => { 56 + optionsMenuControl.open() 57 + }}> 58 + {({hovered, pressed}) => ( 59 + <> 60 + <View 61 + style={[ 62 + a.absolute, 63 + a.inset_0, 64 + a.rounded_full, 65 + { 66 + backgroundColor: layout.overlayColor, 67 + opacity: hovered || pressed ? 0.8 : 0.6, 68 + }, 69 + ]} 70 + /> 71 + <EllipsisIcon 72 + size="sm" 73 + fill={layout.textColor} 74 + style={[a.z_20]} 75 + /> 76 + </> 77 + )} 78 + </Button> 79 + </View> 80 + </View> 81 + 82 + <LiveEventFeedOptionsMenu 83 + feed={feed} 84 + control={optionsMenuControl} 85 + metricContext="discover" 86 + /> 87 + </> 88 + ) 89 + }
+17
src/features/liveEvents/components/ExploreScreenLiveEventFeedsBanner.tsx
···
··· 1 + import {View} from 'react-native' 2 + 3 + import {atoms as a, useTheme} from '#/alf' 4 + import {LiveEventFeedCardWide} from '#/features/liveEvents/components/LiveEventFeedCardWide' 5 + import {useLiveEvents} from '#/features/liveEvents/context' 6 + 7 + export function ExploreScreenLiveEventFeedsBanner() { 8 + const t = useTheme() 9 + const events = useLiveEvents() 10 + return events.feeds.map(feed => ( 11 + <View 12 + key={feed.id} 13 + style={[a.p_lg, a.border_b, t.atoms.border_contrast_low]}> 14 + <LiveEventFeedCardWide feed={feed} metricContext="explore" /> 15 + </View> 16 + )) 17 + }
+132
src/features/liveEvents/components/LiveEventFeedCardCompact.tsx
···
··· 1 + import {useEffect, useMemo} from 'react' 2 + import {View} from 'react-native' 3 + import {Image} from 'expo-image' 4 + import {LinearGradient} from 'expo-linear-gradient' 5 + import {msg} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + 8 + import {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers' 9 + import {logger} from '#/logger' 10 + import {atoms as a, utils} from '#/alf' 11 + import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' 12 + import {Link} from '#/components/Link' 13 + import {Text} from '#/components/Typography' 14 + import { 15 + type LiveEventFeed, 16 + type LiveEventFeedMetricContext, 17 + } from '#/features/liveEvents/types' 18 + 19 + const roundedStyles = [a.rounded_md, a.curve_continuous] 20 + 21 + export function LiveEventFeedCardCompact({ 22 + feed, 23 + metricContext, 24 + }: { 25 + feed: LiveEventFeed 26 + metricContext: LiveEventFeedMetricContext 27 + }) { 28 + const {_} = useLingui() 29 + 30 + const layout = feed.layouts.compact 31 + const overlayColor = layout.overlayColor 32 + const textColor = layout.textColor 33 + const url = useMemo(() => { 34 + // Validated in multiple places on the backend 35 + if (isBskyCustomFeedUrl(feed.url)) { 36 + return new URL(feed.url).pathname 37 + } 38 + return '/' 39 + }, [feed.url]) 40 + 41 + useEffect(() => { 42 + logger.metric('liveEvents:feedBanner:seen', { 43 + feed: feed.url, 44 + context: metricContext, 45 + }) 46 + // eslint-disable-next-line react-hooks/exhaustive-deps 47 + }, []) 48 + 49 + return ( 50 + <Link 51 + to={url} 52 + label={_(msg`Live event happening now: ${feed.title}`)} 53 + style={[a.w_full]} 54 + onPress={() => { 55 + logger.metric('liveEvents:feedBanner:click', { 56 + feed: feed.url, 57 + context: metricContext, 58 + }) 59 + }}> 60 + {({hovered, pressed}) => ( 61 + <View style={[roundedStyles, a.shadow_md, a.w_full]}> 62 + <View 63 + style={[a.w_full, a.align_start, a.overflow_hidden, roundedStyles]}> 64 + <Image 65 + accessibilityIgnoresInvertColors 66 + source={{uri: layout.image}} 67 + placeholder={{blurhash: layout.blurhash}} 68 + style={[a.absolute, a.inset_0, a.w_full, a.h_full]} 69 + contentFit="cover" 70 + placeholderContentFit="cover" 71 + /> 72 + 73 + <LinearGradient 74 + colors={[overlayColor, utils.alpha(overlayColor, 0)]} 75 + locations={[0, 1]} 76 + start={{x: 0, y: 0}} 77 + end={{x: 1, y: 0}} 78 + style={[ 79 + a.absolute, 80 + a.inset_0, 81 + a.transition_opacity, 82 + { 83 + transitionDuration: '200ms', 84 + opacity: hovered || pressed ? 0.6 : 0, 85 + }, 86 + ]} 87 + /> 88 + 89 + <View style={[a.w_full, a.justify_end]}> 90 + <LinearGradient 91 + colors={[ 92 + overlayColor, 93 + utils.alpha(overlayColor, 0.7), 94 + utils.alpha(overlayColor, 0), 95 + ]} 96 + locations={[0, 0.8, 1]} 97 + start={{x: 0, y: 0}} 98 + end={{x: 1, y: 0}} 99 + style={[a.absolute, a.inset_0]} 100 + /> 101 + 102 + <View 103 + style={[ 104 + a.flex_1, 105 + a.flex_row, 106 + a.align_center, 107 + a.gap_xs, 108 + a.z_10, 109 + a.px_lg, 110 + a.py_md, 111 + ]}> 112 + <LiveIcon size="md" fill={textColor} /> 113 + <Text 114 + numberOfLines={1} 115 + style={[ 116 + a.flex_1, 117 + a.leading_snug, 118 + a.font_bold, 119 + a.text_lg, 120 + a.pr_xl, 121 + {color: textColor}, 122 + ]}> 123 + {layout.title} 124 + </Text> 125 + </View> 126 + </View> 127 + </View> 128 + </View> 129 + )} 130 + </Link> 131 + ) 132 + }
+139
src/features/liveEvents/components/LiveEventFeedCardWide.tsx
···
··· 1 + import {useEffect, useMemo} from 'react' 2 + import {View} from 'react-native' 3 + import {Image} from 'expo-image' 4 + import {LinearGradient} from 'expo-linear-gradient' 5 + import {msg, Trans} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + 8 + import {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers' 9 + import {logger} from '#/logger' 10 + import {atoms as a, useBreakpoints, utils} from '#/alf' 11 + import {Link} from '#/components/Link' 12 + import {Text} from '#/components/Typography' 13 + import { 14 + type LiveEventFeed, 15 + type LiveEventFeedMetricContext, 16 + } from '#/features/liveEvents/types' 17 + 18 + const roundedStyles = [a.rounded_lg, a.curve_continuous] 19 + 20 + export function LiveEventFeedCardWide({ 21 + feed, 22 + metricContext, 23 + }: { 24 + feed: LiveEventFeed 25 + metricContext: LiveEventFeedMetricContext 26 + }) { 27 + const {_} = useLingui() 28 + const {gtPhone} = useBreakpoints() 29 + 30 + const layout = feed.layouts.wide 31 + const overlayColor = layout.overlayColor 32 + const textColor = layout.textColor 33 + const url = useMemo(() => { 34 + // Validated in multiple places on the backend 35 + if (isBskyCustomFeedUrl(feed.url)) { 36 + return new URL(feed.url).pathname 37 + } 38 + return '/' 39 + }, [feed.url]) 40 + 41 + useEffect(() => { 42 + logger.metric('liveEvents:feedBanner:seen', { 43 + feed: feed.url, 44 + context: metricContext, 45 + }) 46 + // eslint-disable-next-line react-hooks/exhaustive-deps 47 + }, []) 48 + 49 + return ( 50 + <Link 51 + to={url} 52 + label={_(msg`Live event happening now: ${feed.title}`)} 53 + style={[a.w_full]} 54 + onPress={() => { 55 + logger.metric('liveEvents:feedBanner:click', { 56 + feed: feed.url, 57 + context: metricContext, 58 + }) 59 + }}> 60 + {({hovered, pressed}) => ( 61 + <View style={[roundedStyles, a.shadow_md, a.w_full]}> 62 + <View 63 + style={[ 64 + a.align_start, 65 + roundedStyles, 66 + a.overflow_hidden, 67 + { 68 + aspectRatio: gtPhone ? 576 / 144 : 369 / 100, 69 + }, 70 + ]}> 71 + <Image 72 + accessibilityIgnoresInvertColors 73 + source={{uri: layout.image}} 74 + placeholder={{blurhash: layout.blurhash}} 75 + style={[a.absolute, a.inset_0, a.w_full, a.h_full]} 76 + contentFit="cover" 77 + placeholderContentFit="cover" 78 + /> 79 + 80 + <LinearGradient 81 + colors={[overlayColor, utils.alpha(overlayColor, 0)]} 82 + locations={[0, 1]} 83 + start={{x: 0, y: 0}} 84 + end={{x: 1, y: 0}} 85 + style={[ 86 + a.absolute, 87 + a.inset_0, 88 + a.transition_opacity, 89 + { 90 + transitionDuration: '200ms', 91 + opacity: hovered || pressed ? 0.6 : 0, 92 + }, 93 + ]} 94 + /> 95 + 96 + <View style={[a.flex_1, a.justify_end]}> 97 + <LinearGradient 98 + colors={[overlayColor, utils.alpha(overlayColor, 0)]} 99 + locations={[0, 1]} 100 + start={{x: 0, y: 0}} 101 + end={{x: 1, y: 0}} 102 + style={[a.absolute, a.inset_0]} 103 + /> 104 + 105 + <View 106 + style={[ 107 + a.z_10, 108 + gtPhone ? [a.pl_xl, a.pb_lg] : [a.pl_lg, a.pb_md], 109 + {paddingRight: 64}, 110 + ]}> 111 + <Text 112 + style={[ 113 + a.leading_snug, 114 + gtPhone ? a.text_xs : a.text_2xs, 115 + {color: textColor, opacity: 0.8}, 116 + ]}> 117 + {feed.preview ? ( 118 + <Trans>Preview</Trans> 119 + ) : ( 120 + <Trans>Happening now</Trans> 121 + )} 122 + </Text> 123 + <Text 124 + style={[ 125 + a.leading_snug, 126 + a.font_bold, 127 + gtPhone ? a.text_3xl : a.text_lg, 128 + {color: textColor}, 129 + ]}> 130 + {layout.title} 131 + </Text> 132 + </View> 133 + </View> 134 + </View> 135 + </View> 136 + )} 137 + </Link> 138 + ) 139 + }
+171
src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx
···
··· 1 + import {View} from 'react-native' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {useCleanError} from '#/lib/hooks/useCleanError' 6 + import {isNative} from '#/platform/detection' 7 + import {atoms as a, web} from '#/alf' 8 + import {Admonition} from '#/components/Admonition' 9 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 10 + import * as Dialog from '#/components/Dialog' 11 + import {Loader} from '#/components/Loader' 12 + import * as toast from '#/components/Toast' 13 + import {Span, Text} from '#/components/Typography' 14 + import {useUpdateLiveEventPreferences} from '#/features/liveEvents/preferences' 15 + import { 16 + type LiveEventFeed, 17 + type LiveEventFeedMetricContext, 18 + } from '#/features/liveEvents/types' 19 + 20 + export {useDialogControl} from '#/components/Dialog' 21 + 22 + export function LiveEventFeedOptionsMenu({ 23 + control, 24 + feed, 25 + metricContext, 26 + }: { 27 + control: Dialog.DialogControlProps 28 + feed: LiveEventFeed 29 + metricContext: LiveEventFeedMetricContext 30 + }) { 31 + const {_} = useLingui() 32 + return ( 33 + <Dialog.Outer control={control}> 34 + <Dialog.Handle /> 35 + <Dialog.ScrollableInner 36 + label={_(msg`Configure live event banner`)} 37 + style={[web({maxWidth: 400})]}> 38 + <Inner control={control} feed={feed} metricContext={metricContext} /> 39 + <Dialog.Close /> 40 + </Dialog.ScrollableInner> 41 + </Dialog.Outer> 42 + ) 43 + } 44 + 45 + function Inner({ 46 + control, 47 + feed, 48 + metricContext, 49 + }: { 50 + control: Dialog.DialogControlProps 51 + feed: LiveEventFeed 52 + metricContext: LiveEventFeedMetricContext 53 + }) { 54 + const {_} = useLingui() 55 + const { 56 + isPending, 57 + mutate: update, 58 + error: rawError, 59 + variables, 60 + } = useUpdateLiveEventPreferences({ 61 + feed, 62 + metricContext, 63 + onUpdateSuccess({undoAction}) { 64 + toast.show( 65 + <toast.Outer> 66 + <toast.Icon /> 67 + <toast.Text> 68 + <Trans>Your live event preferences have been updated.</Trans> 69 + </toast.Text> 70 + {undoAction && ( 71 + <toast.Action 72 + label={_(msg`Undo`)} 73 + onPress={() => { 74 + if (undoAction) { 75 + update(undoAction) 76 + } 77 + }}> 78 + <Trans>Undo</Trans> 79 + </toast.Action> 80 + )} 81 + </toast.Outer>, 82 + { 83 + type: 'success', 84 + }, 85 + ) 86 + 87 + /* 88 + * If there is no `undoAction`, it means that the action was already 89 + * undone, and therefore the menu would have been closed prior to the 90 + * undo happening. 91 + */ 92 + if (undoAction) { 93 + control.close() 94 + } 95 + }, 96 + }) 97 + const cleanError = useCleanError() 98 + const error = rawError ? cleanError(rawError) : undefined 99 + 100 + const isHidingFeed = variables?.type === 'hideFeed' && isPending 101 + const isHidingAllFeeds = variables?.type === 'toggleHideAllFeeds' && isPending 102 + 103 + return ( 104 + <View style={[a.gap_lg]}> 105 + <View style={[a.gap_sm]}> 106 + <Text style={[a.text_2xl, a.font_semi_bold, a.leading_snug]}> 107 + <Trans>Live event options</Trans> 108 + </Text> 109 + 110 + <Text style={[a.text_md, a.leading_snug]}> 111 + <Trans> 112 + Live events appear occasionally when something exciting is 113 + happening. If you'd like, you can hide this particular event, or all 114 + events for this placement in your app interface. 115 + </Trans> 116 + </Text> 117 + 118 + <Text style={[a.text_md, a.leading_snug]}> 119 + <Trans> 120 + If you choose to hide all events, you can always re-enable them from{' '} 121 + <Span style={[a.font_semi_bold]}>Settings → Content & Media</Span>. 122 + </Trans> 123 + </Text> 124 + </View> 125 + 126 + <View style={[a.gap_sm]}> 127 + <Button 128 + label={_(msg`Hide this event`)} 129 + size="large" 130 + color="primary_subtle" 131 + onPress={() => { 132 + update({type: 'hideFeed', id: feed.id}) 133 + }}> 134 + <ButtonText> 135 + <Trans>Hide this event</Trans> 136 + </ButtonText> 137 + {isHidingFeed && <ButtonIcon icon={Loader} />} 138 + </Button> 139 + <Button 140 + label={_(msg`Hide all events`)} 141 + size="large" 142 + color="secondary" 143 + onPress={() => { 144 + update({type: 'toggleHideAllFeeds'}) 145 + }}> 146 + <ButtonText> 147 + <Trans>Hide all events</Trans> 148 + </ButtonText> 149 + {isHidingAllFeeds && <ButtonIcon icon={Loader} />} 150 + </Button> 151 + {isNative && ( 152 + <Button 153 + label={_(msg`Cancel`)} 154 + size="large" 155 + color="secondary_inverted" 156 + onPress={() => control.close()}> 157 + <ButtonText> 158 + <Trans>Cancel</Trans> 159 + </ButtonText> 160 + </Button> 161 + )} 162 + </View> 163 + 164 + {error && ( 165 + <Admonition type="error"> 166 + {error.clean || error.raw || _(msg`An unknown error occurred.`)} 167 + </Admonition> 168 + )} 169 + </View> 170 + ) 171 + }
+43
src/features/liveEvents/components/LiveEventFeedsSettingsToggle.tsx
···
··· 1 + import {msg, Trans} from '@lingui/macro' 2 + import {useLingui} from '@lingui/react' 3 + 4 + import * as SettingsList from '#/screens/Settings/components/SettingsList' 5 + import * as Toggle from '#/components/forms/Toggle' 6 + import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' 7 + import { 8 + useLiveEventPreferences, 9 + useUpdateLiveEventPreferences, 10 + } from '#/features/liveEvents/preferences' 11 + 12 + export function LiveEventFeedsSettingsToggle() { 13 + const {_} = useLingui() 14 + const {data: prefs} = useLiveEventPreferences() 15 + const { 16 + isPending, 17 + data: updatedPrefs, 18 + mutate: update, 19 + } = useUpdateLiveEventPreferences({ 20 + metricContext: 'settings', 21 + }) 22 + const hideAllFeeds = !!(updatedPrefs || prefs)?.hideAllFeeds 23 + 24 + return ( 25 + <Toggle.Item 26 + name="enable_live_event_banner" 27 + label={_(msg`Show live events in your Discover Feed`)} 28 + value={!hideAllFeeds} 29 + onChange={() => { 30 + if (!isPending) { 31 + update({type: 'toggleHideAllFeeds'}) 32 + } 33 + }}> 34 + <SettingsList.Item> 35 + <SettingsList.ItemIcon icon={LiveIcon} /> 36 + <SettingsList.ItemText> 37 + <Trans>Show live events in your Discover Feed</Trans> 38 + </SettingsList.ItemText> 39 + <Toggle.Platform /> 40 + </SettingsList.Item> 41 + </Toggle.Item> 42 + ) 43 + }
+13
src/features/liveEvents/components/SidebarLiveEventFeedsBanner.tsx
···
··· 1 + import {LiveEventFeedCardCompact} from '#/features/liveEvents/components/LiveEventFeedCardCompact' 2 + import {useLiveEvents} from '#/features/liveEvents/context' 3 + 4 + export function SidebarLiveEventFeedsBanner() { 5 + const events = useLiveEvents() 6 + return events.feeds.map(feed => ( 7 + <LiveEventFeedCardCompact 8 + key={feed.id} 9 + feed={feed} 10 + metricContext="sidebar" 11 + /> 12 + )) 13 + }
+110
src/features/liveEvents/context.tsx
···
··· 1 + import {createContext, useContext, useMemo} from 'react' 2 + import {QueryClient, useQuery} from '@tanstack/react-query' 3 + 4 + import {useIsBskyTeam} from '#/lib/hooks/useIsBskyTeam' 5 + import { 6 + convertBskyAppUrlIfNeeded, 7 + isBskyCustomFeedUrl, 8 + makeRecordUri, 9 + } from '#/lib/strings/url-helpers' 10 + import {IS_DEV, LIVE_EVENTS_URL} from '#/env' 11 + import {useLiveEventPreferences} from '#/features/liveEvents/preferences' 12 + import {type LiveEventsWorkerResponse} from '#/features/liveEvents/types' 13 + import {useDevMode} from '#/storage/hooks/dev-mode' 14 + 15 + const qc = new QueryClient() 16 + const liveEventsQueryKey = ['live-events'] 17 + 18 + export const DEFAULT_LIVE_EVENTS = { 19 + feeds: [], 20 + } 21 + 22 + async function fetchLiveEvents(): Promise<LiveEventsWorkerResponse | null> { 23 + try { 24 + const res = await fetch(`${LIVE_EVENTS_URL}/config`) 25 + if (!res.ok) return null 26 + const data = await res.json() 27 + return data 28 + } catch { 29 + return null 30 + } 31 + } 32 + 33 + const Context = createContext<LiveEventsWorkerResponse>(DEFAULT_LIVE_EVENTS) 34 + 35 + export function Provider({children}: React.PropsWithChildren<{}>) { 36 + const [isDevMode] = useDevMode() 37 + const isBskyTeam = useIsBskyTeam() 38 + const {data} = useQuery( 39 + { 40 + staleTime: IS_DEV ? 5e3 : 1000 * 60, 41 + queryKey: liveEventsQueryKey, 42 + async queryFn() { 43 + return fetchLiveEvents() 44 + }, 45 + }, 46 + qc, 47 + ) 48 + 49 + const ctx = useMemo(() => { 50 + if (!data) return DEFAULT_LIVE_EVENTS 51 + const feeds = data.feeds.filter(f => { 52 + if (f.preview && !isBskyTeam) return false 53 + return true 54 + }) 55 + return { 56 + ...data, 57 + // only one at a time for now, unless bsky team and dev mode 58 + feeds: isBskyTeam && isDevMode ? feeds : feeds.slice(0, 1), 59 + } 60 + }, [data, isBskyTeam, isDevMode]) 61 + 62 + return <Context.Provider value={ctx}>{children}</Context.Provider> 63 + } 64 + 65 + export async function prefetchLiveEvents() { 66 + const data = await fetchLiveEvents() 67 + if (data) { 68 + qc.setQueryData(liveEventsQueryKey, data) 69 + } 70 + } 71 + 72 + export function useLiveEvents() { 73 + const ctx = useContext(Context) 74 + if (!ctx) { 75 + throw new Error('useLiveEventsContext must be used within a Provider') 76 + } 77 + return ctx 78 + } 79 + 80 + export function useUserPreferencedLiveEvents() { 81 + const events = useLiveEvents() 82 + const {data, isLoading} = useLiveEventPreferences() 83 + if (isLoading) return DEFAULT_LIVE_EVENTS 84 + const {hideAllFeeds, hiddenFeedIds} = data 85 + return { 86 + ...events, 87 + feeds: hideAllFeeds 88 + ? [] 89 + : events.feeds.filter(f => { 90 + const hidden = f?.id ? hiddenFeedIds.includes(f?.id || '') : false 91 + return !hidden 92 + }), 93 + } 94 + } 95 + 96 + export function useActiveLiveEventFeedUris() { 97 + const {feeds} = useLiveEvents() 98 + 99 + return new Set( 100 + feeds 101 + // insurance 102 + .filter(f => isBskyCustomFeedUrl(f.url)) 103 + .map(f => { 104 + const uri = convertBskyAppUrlIfNeeded(f.url) 105 + const [_0, did, _1, rkey] = uri.split('/').filter(Boolean) 106 + const urip = makeRecordUri(did, 'app.bsky.feed.generator', rkey) 107 + return urip.toString() 108 + }), 109 + ) 110 + }
+161
src/features/liveEvents/preferences.ts
···
··· 1 + import {useEffect} from 'react' 2 + import {type Agent, AppBskyActorDefs, asPredicate} from '@atproto/api' 3 + import {useMutation, useQueryClient} from '@tanstack/react-query' 4 + 5 + import {logger} from '#/logger' 6 + import {isWeb} from '#/platform/detection' 7 + import { 8 + preferencesQueryKey, 9 + usePreferencesQuery, 10 + } from '#/state/queries/preferences' 11 + import {useAgent} from '#/state/session' 12 + import * as env from '#/env' 13 + import { 14 + type LiveEventFeed, 15 + type LiveEventFeedMetricContext, 16 + } from '#/features/liveEvents/types' 17 + 18 + export type LiveEventPreferencesAction = Parameters< 19 + Agent['updateLiveEventPreferences'] 20 + >[0] & { 21 + /** 22 + * Flag that is internal to this hook, do not set when updating prefs 23 + */ 24 + __canUndo?: boolean 25 + } 26 + 27 + export function useLiveEventPreferences() { 28 + const query = usePreferencesQuery() 29 + useWebOnlyDebugLiveEventPreferences() 30 + return { 31 + ...query, 32 + data: query.data?.liveEventPreferences || { 33 + hideAllFeeds: false, 34 + hiddenFeedIds: [], 35 + }, 36 + } 37 + } 38 + 39 + function useWebOnlyDebugLiveEventPreferences() { 40 + const queryClient = useQueryClient() 41 + const agent = useAgent() 42 + 43 + useEffect(() => { 44 + if (env.IS_DEV && isWeb && typeof window !== 'undefined') { 45 + // @ts-ignore 46 + window.__updateLiveEventPreferences = async ( 47 + action: LiveEventPreferencesAction, 48 + ) => { 49 + await agent.updateLiveEventPreferences(action) 50 + // triggers a refetch 51 + await queryClient.invalidateQueries({ 52 + queryKey: preferencesQueryKey, 53 + }) 54 + } 55 + } 56 + }, [agent, queryClient]) 57 + } 58 + 59 + export function useUpdateLiveEventPreferences(props: { 60 + feed?: LiveEventFeed 61 + metricContext: LiveEventFeedMetricContext 62 + onUpdateSuccess?: (props: { 63 + undoAction: LiveEventPreferencesAction | null 64 + }) => void 65 + }) { 66 + const queryClient = useQueryClient() 67 + const agent = useAgent() 68 + 69 + return useMutation< 70 + AppBskyActorDefs.LiveEventPreferences, 71 + Error, 72 + LiveEventPreferencesAction, 73 + {undoAction: LiveEventPreferencesAction | null} 74 + >({ 75 + onSettled(data, error, variables) { 76 + /* 77 + * `onSettled` runs after the mutation completes, success or no. The idea 78 + * here is that we want to invert the action that was just passed in, and 79 + * provide it as an `undoAction` to the `onUpdateSuccess` callback. 80 + * 81 + * If the operation was not a success, we don't provide the `undoAction`. 82 + * 83 + * Upon the first call of the mutation, the `__canUndo` flag is undefined, 84 + * so we allow the undo. However, when we create the `undoAction`, we 85 + * set its `__canUndo` flag to false, so that if the user were to call 86 + * the undo action, we would not provide another undo for that. 87 + */ 88 + const canUndo = variables.__canUndo === undefined ? true : false 89 + let undoAction: LiveEventPreferencesAction | null = null 90 + 91 + switch (variables.type) { 92 + case 'hideFeed': 93 + undoAction = {type: 'unhideFeed', id: variables.id, __canUndo: false} 94 + break 95 + case 'unhideFeed': 96 + undoAction = {type: 'hideFeed', id: variables.id, __canUndo: false} 97 + break 98 + case 'toggleHideAllFeeds': 99 + undoAction = {type: 'toggleHideAllFeeds', __canUndo: false} 100 + break 101 + } 102 + 103 + if (data && !error) { 104 + props?.onUpdateSuccess?.({ 105 + undoAction: canUndo ? undoAction : null, 106 + }) 107 + } 108 + }, 109 + mutationFn: async action => { 110 + const updated = await agent.updateLiveEventPreferences(action) 111 + const prefs = updated.find(p => 112 + asPredicate(AppBskyActorDefs.validateLiveEventPreferences)(p), 113 + ) 114 + 115 + switch (action.type) { 116 + case 'hideFeed': 117 + case 'unhideFeed': { 118 + if (!props.feed) { 119 + logger.error( 120 + `useUpdateLiveEventPreferences: feed is missing, but required for hiding/unhiding`, 121 + { 122 + action, 123 + }, 124 + ) 125 + break 126 + } 127 + 128 + logger.metric( 129 + action.type === 'hideFeed' 130 + ? 'liveEvents:feedBanner:hide' 131 + : 'liveEvents:feedBanner:unhide', 132 + { 133 + feed: props.feed.url, 134 + context: props.metricContext, 135 + }, 136 + ) 137 + break 138 + } 139 + case 'toggleHideAllFeeds': { 140 + if (prefs!.hideAllFeeds) { 141 + logger.metric('liveEvents:hideAllFeedBanners', { 142 + context: props.metricContext, 143 + }) 144 + } else { 145 + logger.metric('liveEvents:unhideAllFeedBanners', { 146 + context: props.metricContext, 147 + }) 148 + } 149 + break 150 + } 151 + } 152 + 153 + // triggers a refetch 154 + queryClient.invalidateQueries({ 155 + queryKey: preferencesQueryKey, 156 + }) 157 + 158 + return prefs! 159 + }, 160 + }) 161 + }
+27
src/features/liveEvents/types.ts
···
··· 1 + export type LiveEventFeedImageLayout = 'wide' | 'compact' // maybe more in the future 2 + 3 + export type LiveEventFeedLayout = { 4 + title: string 5 + overlayColor: string 6 + textColor: string 7 + image: string 8 + blurhash: string 9 + } 10 + 11 + export type LiveEventFeed = { 12 + id: string 13 + preview: boolean 14 + title: string 15 + url: string 16 + layouts: Record<LiveEventFeedImageLayout, LiveEventFeedLayout> 17 + } 18 + 19 + export type LiveEventsWorkerResponse = { 20 + feeds: LiveEventFeed[] 21 + } 22 + 23 + export type LiveEventFeedMetricContext = 24 + | 'explore' 25 + | 'discover' 26 + | 'sidebar' 27 + | 'settings'
+2 -2
src/geolocation/const.ts
··· 1 - import {BAPP_CONFIG_URL} from '#/env' 2 import {type Geolocation} from '#/geolocation/types' 3 4 - export const GEOLOCATION_SERVICE_URL = `${BAPP_CONFIG_URL}/geolocation` 5 6 /** 7 * Default geolocation config.
··· 1 + import {GEOLOCATION_URL} from '#/env' 2 import {type Geolocation} from '#/geolocation/types' 3 4 + export const GEOLOCATION_SERVICE_URL = `${GEOLOCATION_URL}/geolocation` 5 6 /** 7 * Default geolocation config.
+8
src/lib/hooks/useIsBskyTeam.ts
···
··· 1 + import {useMemo} from 'react' 2 + 3 + import {useGate} from '#/lib/statsig/statsig' 4 + 5 + export function useIsBskyTeam() { 6 + const gate = useGate() 7 + return useMemo(() => gate('is_bsky_team_member'), [gate]) 8 + }
+1
src/lib/statsig/gates.ts
··· 7 | 'disable_settings_find_contacts' 8 | 'explore_show_suggested_feeds' 9 | 'feed_reply_button_open_thread' 10 | 'old_postonboarding' 11 | 'onboarding_add_video_feed' 12 | 'onboarding_suggested_starterpacks'
··· 7 | 'disable_settings_find_contacts' 8 | 'explore_show_suggested_feeds' 9 | 'feed_reply_button_open_thread' 10 + | 'is_bsky_team_member' // special, do not remove 11 | 'old_postonboarding' 12 | 'onboarding_add_video_feed' 13 | 'onboarding_suggested_starterpacks'
+2
src/logger/index.ts
··· 16 import {isNative} from '#/platform/detection' 17 import {ENV} from '#/env' 18 19 const TRANSPORTS: Transport[] = (function configureTransports() { 20 switch (ENV) { 21 case 'production': {
··· 16 import {isNative} from '#/platform/detection' 17 import {ENV} from '#/env' 18 19 + export {type MetricEvents as Metrics} from '#/logger/metrics' 20 + 21 const TRANSPORTS: Transport[] = (function configureTransports() { 22 switch (ENV) { 23 case 'production': {
+24
src/logger/metrics.ts
··· 1 import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' 2 import {type FeedDescriptor} from '#/state/queries/post-feed' 3 4 export type MetricEvents = { 5 // App events ··· 796 } 797 // user pressed the remove all data button 798 'contacts:settings:removeData': {} 799 }
··· 1 import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' 2 import {type FeedDescriptor} from '#/state/queries/post-feed' 3 + import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' 4 5 export type MetricEvents = { 6 // App events ··· 797 } 798 // user pressed the remove all data button 799 'contacts:settings:removeData': {} 800 + 801 + 'liveEvents:feedBanner:seen': { 802 + feed: string 803 + context: LiveEventFeedMetricContext 804 + } 805 + 'liveEvents:feedBanner:click': { 806 + feed: string 807 + context: LiveEventFeedMetricContext 808 + } 809 + 'liveEvents:feedBanner:hide': { 810 + feed: string 811 + context: LiveEventFeedMetricContext 812 + } 813 + 'liveEvents:feedBanner:unhide': { 814 + feed: string 815 + context: LiveEventFeedMetricContext 816 + } 817 + 'liveEvents:hideAllFeedBanners': { 818 + context: LiveEventFeedMetricContext 819 + } 820 + 'liveEvents:unhideAllFeedBanners': { 821 + context: LiveEventFeedMetricContext 822 + } 823 }
+10
src/screens/Search/Explore.tsx
··· 68 import * as ProfileCard from '#/components/ProfileCard' 69 import {SubtleHover} from '#/components/SubtleHover' 70 import {Text} from '#/components/Typography' 71 import * as ModuleHeader from './components/ModuleHeader' 72 import { 73 SuggestedAccountsTabBar, ··· 200 | { 201 type: 'interests-card' 202 key: 'interests-card' 203 } 204 205 export function Explore({ ··· 684 i.push(topBorder) 685 i.push(...interestsNuxModule) 686 687 if (useFullExperience) { 688 i.push(trendingTopicsModule) 689 i.push(...suggestedFeedsModule) ··· 997 } 998 case 'interests-card': { 999 return <ExploreInterestsCard /> 1000 } 1001 } 1002 },
··· 68 import * as ProfileCard from '#/components/ProfileCard' 69 import {SubtleHover} from '#/components/SubtleHover' 70 import {Text} from '#/components/Typography' 71 + import {ExploreScreenLiveEventFeedsBanner} from '#/features/liveEvents/components/ExploreScreenLiveEventFeedsBanner' 72 import * as ModuleHeader from './components/ModuleHeader' 73 import { 74 SuggestedAccountsTabBar, ··· 201 | { 202 type: 'interests-card' 203 key: 'interests-card' 204 + } 205 + | { 206 + type: 'liveEventFeedsBanner' 207 + key: string 208 } 209 210 export function Explore({ ··· 689 i.push(topBorder) 690 i.push(...interestsNuxModule) 691 692 + i.push({type: 'liveEventFeedsBanner', key: 'liveEventFeedsBanner'}) 693 + 694 if (useFullExperience) { 695 i.push(trendingTopicsModule) 696 i.push(...suggestedFeedsModule) ··· 1004 } 1005 case 'interests-card': { 1006 return <ExploreInterestsCard /> 1007 + } 1008 + case 'liveEventFeedsBanner': { 1009 + return <ExploreScreenLiveEventFeedsBanner /> 1010 } 1011 } 1012 },
+8 -1
src/screens/Settings/ContentAndMediaSettings.tsx
··· 26 import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' 27 import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' 28 import * as Layout from '#/components/Layout' 29 30 type Props = NativeStackScreenProps< 31 CommonNavigatorParams, ··· 124 <Toggle.Platform /> 125 </SettingsList.Item> 126 </Toggle.Item> 127 - {trendingEnabled && ( 128 <> 129 <SettingsList.Divider /> 130 <Toggle.Item ··· 148 <Toggle.Platform /> 149 </SettingsList.Item> 150 </Toggle.Item> 151 <Toggle.Item 152 name="show_trending_videos" 153 label={_(msg`Enable trending videos in your Discover feed`)} ··· 169 <Toggle.Platform /> 170 </SettingsList.Item> 171 </Toggle.Item> 172 </> 173 )} 174 </SettingsList.Container>
··· 26 import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' 27 import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' 28 import * as Layout from '#/components/Layout' 29 + import {LiveEventFeedsSettingsToggle} from '#/features/liveEvents/components/LiveEventFeedsSettingsToggle' 30 31 type Props = NativeStackScreenProps< 32 CommonNavigatorParams, ··· 125 <Toggle.Platform /> 126 </SettingsList.Item> 127 </Toggle.Item> 128 + {trendingEnabled ? ( 129 <> 130 <SettingsList.Divider /> 131 <Toggle.Item ··· 149 <Toggle.Platform /> 150 </SettingsList.Item> 151 </Toggle.Item> 152 + <LiveEventFeedsSettingsToggle /> 153 <Toggle.Item 154 name="show_trending_videos" 155 label={_(msg`Enable trending videos in your Discover feed`)} ··· 171 <Toggle.Platform /> 172 </SettingsList.Item> 173 </Toggle.Item> 174 + </> 175 + ) : ( 176 + <> 177 + <SettingsList.Divider /> 178 + <LiveEventFeedsSettingsToggle /> 179 </> 180 )} 181 </SettingsList.Container>
+4
src/state/queries/preferences/const.ts
··· 45 verificationPrefs: { 46 hideBadges: false, 47 }, 48 }
··· 45 verificationPrefs: { 46 hideBadges: false, 47 }, 48 + liveEventPreferences: { 49 + hideAllFeeds: false, 50 + hiddenFeedIds: [], 51 + }, 52 }
-2
src/view/com/feeds/ComposerPrompt.tsx
··· 148 a.relative, 149 a.flex_row, 150 a.align_start, 151 - a.border_t, 152 - t.atoms.border_contrast_low, 153 { 154 paddingLeft: 18, 155 paddingRight: 15,
··· 148 a.relative, 149 a.flex_row, 150 a.align_start, 151 { 152 paddingLeft: 18, 153 paddingRight: 15,
+12 -10
src/view/com/posts/PostFeed.tsx
··· 70 } from '#/components/feeds/PostFeedVideoGridRow' 71 import {TrendingInterstitial} from '#/components/interstitials/Trending' 72 import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' 73 import {ComposerPrompt} from '../feeds/ComposerPrompt' 74 import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' 75 import {FeedShutdownMsg} from './FeedShutdownMsg' ··· 153 } 154 | { 155 type: 'composerPrompt' 156 key: string 157 } 158 ··· 360 const showProgressIntersitial = 361 (followProgressGuide || followAndLikeProgressGuide) && !rightNavVisible 362 363 - const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings() 364 365 const ageAssuranceBannerState = useAgeAssuranceBannerState() 366 const selectedFeed = useSelectedFeed() ··· 510 }) 511 } 512 } 513 - if (!rightNavVisible && !trendingDisabled) { 514 - arr.push({ 515 - type: 'interstitialTrending', 516 - key: 517 - 'interstitial2-' + sliceIndex + '-' + lastFetchedAt, 518 - }) 519 - } 520 // Show composer prompt for Discover and Following feeds 521 if ( 522 hasSession && ··· 672 feedTab, 673 hasSession, 674 showProgressIntersitial, 675 - trendingDisabled, 676 trendingVideoDisabled, 677 - rightNavVisible, 678 gtMobile, 679 isVideoFeed, 680 areVideoFeedsEnabled, ··· 773 return <AgeAssuranceDismissibleFeedBanner /> 774 } else if (row.type === 'interstitialTrending') { 775 return <TrendingInterstitial /> 776 } else if (row.type === 'composerPrompt') { 777 return <ComposerPrompt /> 778 } else if (row.type === 'interstitialTrendingVideos') {
··· 70 } from '#/components/feeds/PostFeedVideoGridRow' 71 import {TrendingInterstitial} from '#/components/interstitials/Trending' 72 import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' 73 + import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner' 74 import {ComposerPrompt} from '../feeds/ComposerPrompt' 75 import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' 76 import {FeedShutdownMsg} from './FeedShutdownMsg' ··· 154 } 155 | { 156 type: 'composerPrompt' 157 + key: string 158 + } 159 + | { 160 + type: 'liveEventFeedsAndTrendingBanner' 161 key: string 162 } 163 ··· 365 const showProgressIntersitial = 366 (followProgressGuide || followAndLikeProgressGuide) && !rightNavVisible 367 368 + const {trendingVideoDisabled} = useTrendingSettings() 369 370 const ageAssuranceBannerState = useAgeAssuranceBannerState() 371 const selectedFeed = useSelectedFeed() ··· 515 }) 516 } 517 } 518 + arr.push({ 519 + type: 'liveEventFeedsAndTrendingBanner', 520 + key: 'liveEventFeedsAndTrendingBanner-' + sliceIndex, 521 + }) 522 // Show composer prompt for Discover and Following feeds 523 if ( 524 hasSession && ··· 674 feedTab, 675 hasSession, 676 showProgressIntersitial, 677 trendingVideoDisabled, 678 gtMobile, 679 isVideoFeed, 680 areVideoFeedsEnabled, ··· 773 return <AgeAssuranceDismissibleFeedBanner /> 774 } else if (row.type === 'interstitialTrending') { 775 return <TrendingInterstitial /> 776 + } else if (row.type === 'liveEventFeedsAndTrendingBanner') { 777 + return <DiscoverFeedLiveEventFeedsAndTrendingBanner /> 778 } else if (row.type === 'composerPrompt') { 779 return <ComposerPrompt /> 780 } else if (row.type === 'interstitialTrendingVideos') {
+5 -2
src/view/shell/desktop/RightNav.tsx
··· 22 import {InlineLinkText} from '#/components/Link' 23 import {ProgressGuideList} from '#/components/ProgressGuide/List' 24 import {Text} from '#/components/Typography' 25 26 function useWebQueryParams() { 27 const navigation = useNavigation() ··· 49 const isSearchScreen = routeName === 'Search' 50 const webqueryParams = useWebQueryParams() 51 const searchQuery = webqueryParams?.q 52 - const showTrending = !isSearchScreen || (isSearchScreen && !!searchQuery) 53 const {rightNavVisible, centerColumnOffset, leftNavMinimal} = 54 useLayoutBreakpoints() 55 ··· 90 </> 91 )} 92 93 - {showTrending && <SidebarTrendingTopics />} 94 95 <Text style={[a.leading_snug, t.atoms.text_contrast_low]}> 96 {hasSession && (
··· 22 import {InlineLinkText} from '#/components/Link' 23 import {ProgressGuideList} from '#/components/ProgressGuide/List' 24 import {Text} from '#/components/Typography' 25 + import {SidebarLiveEventFeedsBanner} from '#/features/liveEvents/components/SidebarLiveEventFeedsBanner' 26 27 function useWebQueryParams() { 28 const navigation = useNavigation() ··· 50 const isSearchScreen = routeName === 'Search' 51 const webqueryParams = useWebQueryParams() 52 const searchQuery = webqueryParams?.q 53 + const showExploreScreenDuplicatedContent = 54 + !isSearchScreen || (isSearchScreen && !!searchQuery) 55 const {rightNavVisible, centerColumnOffset, leftNavMinimal} = 56 useLayoutBreakpoints() 57 ··· 92 </> 93 )} 94 95 + {showExploreScreenDuplicatedContent && <SidebarLiveEventFeedsBanner />} 96 + {showExploreScreenDuplicatedContent && <SidebarTrendingTopics />} 97 98 <Text style={[a.leading_snug, t.atoms.text_contrast_low]}> 99 {hasSession && (
+20 -20
yarn.lock
··· 82 "@atproto/xrpc" "^0.7.6" 83 "@atproto/xrpc-server" "^0.10.0" 84 85 - "@atproto/api@^0.18.13": 86 - version "0.18.13" 87 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.13.tgz#63eee310e6715752eb87748323cf9ab57dd91e4b" 88 - integrity sha512-CULZ01pSJDltLS/Gc9MMrhFzB6OM3ezyZw7KoeLT/sBfwgA1ddA4mWdTh7DIRosPRigXtA05bnoiCutZbQDo+Q== 89 dependencies: 90 - "@atproto/common-web" "^0.4.11" 91 "@atproto/lexicon" "^0.6.0" 92 "@atproto/syntax" "^0.4.2" 93 "@atproto/xrpc" "^0.7.7" ··· 208 pino-http "^8.2.1" 209 typed-emitter "^2.1.0" 210 211 - "@atproto/common-web@^0.4.11": 212 - version "0.4.11" 213 - resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.11.tgz#eb41dc02c1ea4221388630e193d181fb098186e0" 214 - integrity sha512-VHejNmSABU8/03VrQ3e36AmT5U3UIeio+qSUqCrO1oNgrJcWfGy1rpj0FVtUugWF8Un29+yzkukzWGZfXL70rQ== 215 dependencies: 216 - "@atproto/lex-data" "0.0.7" 217 - "@atproto/lex-json" "0.0.7" 218 zod "^3.23.8" 219 220 "@atproto/common-web@^0.4.4", "@atproto/common-web@^0.4.6": ··· 415 uint8arrays "3.0.0" 416 unicode-segmenter "^0.14.0" 417 418 - "@atproto/lex-data@0.0.7": 419 - version "0.0.7" 420 - resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.7.tgz#6aa87423f6d47849bec8ff3ca0b00ce93964adc8" 421 - integrity sha512-W/Q5o9o7n2Sv3UywckChu01X5lwQUtaiiOkGJLnRsdkQTyC6813nPgY+p2sG7NwwM+82lu+FUV9fE/Ul3VqaJw== 422 dependencies: 423 "@atproto/syntax" "0.4.2" 424 multiformats "^9.9.0" ··· 451 "@atproto/lex-data" "0.0.3" 452 tslib "^2.8.1" 453 454 - "@atproto/lex-json@0.0.7": 455 - version "0.0.7" 456 - resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.7.tgz#c06e1fc3e06d739bbb74694f5d846055bed37866" 457 - integrity sha512-bjNPD5M/MhLfjNM7tcxuls80UgXpHqxdOxDXEUouAtZQV/nIDhGjmNUvKxOmOgnDsiZRnT2g5y3onrnjH3a44g== 458 dependencies: 459 - "@atproto/lex-data" "0.0.7" 460 tslib "^2.8.1" 461 462 "@atproto/lex-resolver@0.0.5":
··· 82 "@atproto/xrpc" "^0.7.6" 83 "@atproto/xrpc-server" "^0.10.0" 84 85 + "@atproto/api@^0.18.15": 86 + version "0.18.15" 87 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.15.tgz#25ce82081216bdefbf5397220de76ac3ba9e3f5d" 88 + integrity sha512-GeaTP7HMRZa8jD6trMuTACa8t2jkFtRmcwWgrB0FT7l9jVCXrKpYupWeIeauEgWHNwWUUiaq3LmCox+HBy8ZMQ== 89 dependencies: 90 + "@atproto/common-web" "^0.4.12" 91 "@atproto/lexicon" "^0.6.0" 92 "@atproto/syntax" "^0.4.2" 93 "@atproto/xrpc" "^0.7.7" ··· 208 pino-http "^8.2.1" 209 typed-emitter "^2.1.0" 210 211 + "@atproto/common-web@^0.4.12": 212 + version "0.4.12" 213 + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.12.tgz#04135bef480d9e12cfef124ee45d8236764e7509" 214 + integrity sha512-3aCJemqM/fkHQrVPbTCHCdiVstKFI+2LkFLvUhO6XZP0EqUZa/rg/CIZBKTFUWu9I5iYiaEiXL9VwcDRpEevSw== 215 dependencies: 216 + "@atproto/lex-data" "0.0.8" 217 + "@atproto/lex-json" "0.0.8" 218 zod "^3.23.8" 219 220 "@atproto/common-web@^0.4.4", "@atproto/common-web@^0.4.6": ··· 415 uint8arrays "3.0.0" 416 unicode-segmenter "^0.14.0" 417 418 + "@atproto/lex-data@0.0.8": 419 + version "0.0.8" 420 + resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.8.tgz#46cc261efbfa6cc05bf04439d2d73cd8386b467d" 421 + integrity sha512-1Y5tz7BkS7380QuLNXaE8GW8Xba+mRWugt8BKM4BUFYjjUZdmirU8lr72iM4XlEBrzRu8Cfvj+MbsbYaZv+IgA== 422 dependencies: 423 "@atproto/syntax" "0.4.2" 424 multiformats "^9.9.0" ··· 451 "@atproto/lex-data" "0.0.3" 452 tslib "^2.8.1" 453 454 + "@atproto/lex-json@0.0.8": 455 + version "0.0.8" 456 + resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.8.tgz#03290762d9368b029488ee0a0766d1a34063255c" 457 + integrity sha512-w1Qmkae1QhmNz+i1Zm3xr3jp0UPPRENmdlpU0qIrdxWDo9W4Mzkeyc3eSoa+Zs+zN8xkRSQw7RLZte/B7Ipdwg== 458 dependencies: 459 + "@atproto/lex-data" "0.0.8" 460 tslib "^2.8.1" 461 462 "@atproto/lex-resolver@0.0.5":