Bluesky app fork with some witchin' additions 馃挮
fork

Configure Feed

Select the types of activity you want to include in your feed.

at viewport 282 lines 9.1 kB view raw
1import {useCallback} from 'react' 2import {View} from 'react-native' 3import {Image} from 'expo-image' 4import {type AppBskyActorDefs, type AppBskyEmbedExternal} from '@atproto/api' 5import {msg, Trans} from '@lingui/macro' 6import {useLingui} from '@lingui/react' 7import {useNavigation} from '@react-navigation/native' 8import {useQueryClient} from '@tanstack/react-query' 9 10import {useOpenLink} from '#/lib/hooks/useOpenLink' 11import {type NavigationProp} from '#/lib/routes/types' 12import {sanitizeHandle} from '#/lib/strings/handles' 13import {toNiceDomain} from '#/lib/strings/url-helpers' 14import {useModerationOpts} from '#/state/preferences/moderation-opts' 15import {unstableCacheProfileView} from '#/state/queries/profile' 16import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf' 17import {Button, ButtonIcon, ButtonText} from '#/components/Button' 18import * as Dialog from '#/components/Dialog' 19import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' 20import {createStaticClick, SimpleInlineLinkText} from '#/components/Link' 21import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog' 22import * as ProfileCard from '#/components/ProfileCard' 23import {Text} from '#/components/Typography' 24import {useAnalytics} from '#/analytics' 25import type * as bsky from '#/types/bsky' 26import {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe' 27import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight' 28import {LiveIndicator} from './LiveIndicator' 29 30export function LiveStatusDialog({ 31 control, 32 profile, 33 embed, 34 status, 35 onPressViewAvatar, 36}: { 37 control: Dialog.DialogControlProps 38 profile: bsky.profile.AnyProfileView 39 status: AppBskyActorDefs.StatusView 40 embed: AppBskyEmbedExternal.View 41 onPressViewAvatar?: () => void 42}) { 43 const navigation = useNavigation<NavigationProp>() 44 return ( 45 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 46 <Dialog.Handle difference={!!embed.external.thumb} /> 47 <DialogInner 48 status={status} 49 profile={profile} 50 embed={embed} 51 navigation={navigation} 52 onPressViewAvatar={onPressViewAvatar} 53 /> 54 </Dialog.Outer> 55 ) 56} 57 58function DialogInner({ 59 profile, 60 embed, 61 navigation, 62 status, 63 onPressViewAvatar, 64}: { 65 profile: bsky.profile.AnyProfileView 66 embed: AppBskyEmbedExternal.View 67 navigation: NavigationProp 68 status: AppBskyActorDefs.StatusView 69 onPressViewAvatar?: () => void 70}) { 71 const {_} = useLingui() 72 const control = Dialog.useDialogContext() 73 74 const onPressOpenProfile = useCallback(() => { 75 control.close(() => { 76 navigation.push('Profile', { 77 name: profile.handle, 78 }) 79 }) 80 }, [navigation, profile.handle, control]) 81 82 const handlePressViewAvatar = useCallback(() => { 83 if (onPressViewAvatar) { 84 control.close(onPressViewAvatar) 85 } 86 }, [control, onPressViewAvatar]) 87 88 return ( 89 <Dialog.ScrollableInner 90 label={_(msg`${sanitizeHandle(profile.handle)} is live`)} 91 contentContainerStyle={[a.pt_0, a.px_0]} 92 style={[web({maxWidth: 420}), a.overflow_hidden]}> 93 <LiveStatus 94 status={status} 95 profile={profile} 96 embed={embed} 97 onPressOpenProfile={onPressOpenProfile} 98 onPressViewAvatar={handlePressViewAvatar} 99 /> 100 <Dialog.Close /> 101 </Dialog.ScrollableInner> 102 ) 103} 104 105export function LiveStatus({ 106 status, 107 profile, 108 embed, 109 padding = 'xl', 110 onPressOpenProfile, 111 onPressViewAvatar, 112}: { 113 status: AppBskyActorDefs.StatusView 114 profile: bsky.profile.AnyProfileView 115 embed: AppBskyEmbedExternal.View 116 padding?: 'lg' | 'xl' 117 onPressOpenProfile: () => void 118 onPressViewAvatar?: () => void 119}) { 120 const ax = useAnalytics() 121 const {_} = useLingui() 122 const t = useTheme() 123 const queryClient = useQueryClient() 124 const openLink = useOpenLink() 125 const moderationOpts = useModerationOpts() 126 const reportDialogControl = useGlobalReportDialogControl() 127 const dialogContext = Dialog.useDialogContext() 128 129 return ( 130 <> 131 {embed.external.thumb && ( 132 <View 133 style={[ 134 t.atoms.bg_contrast_25, 135 a.w_full, 136 a.aspect_card, 137 android([ 138 a.overflow_hidden, 139 { 140 borderTopLeftRadius: a.rounded_md.borderRadius, 141 borderTopRightRadius: a.rounded_md.borderRadius, 142 }, 143 ]), 144 ]}> 145 <Image 146 source={embed.external.thumb} 147 contentFit="cover" 148 style={[a.absolute, a.inset_0]} 149 accessibilityIgnoresInvertColors 150 /> 151 <LiveIndicator 152 size="large" 153 style={[ 154 a.absolute, 155 {top: tokens.space.lg, left: tokens.space.lg}, 156 a.align_start, 157 ]} 158 /> 159 </View> 160 )} 161 <View 162 style={[ 163 a.gap_lg, 164 padding === 'xl' 165 ? [a.px_xl, !embed.external.thumb ? a.pt_2xl : a.pt_lg] 166 : a.p_lg, 167 ]}> 168 <View style={[a.w_full, a.justify_center, a.gap_2xs]}> 169 <Text 170 numberOfLines={3} 171 style={[a.leading_snug, a.font_semi_bold, a.text_xl]}> 172 {embed.external.title || embed.external.uri} 173 </Text> 174 <View style={[a.flex_row, a.align_center, a.gap_2xs]}> 175 <Globe_Stroke2_Corner0_Rounded 176 size="xs" 177 style={[t.atoms.text_contrast_medium]} 178 /> 179 <Text 180 numberOfLines={1} 181 style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 182 {toNiceDomain(embed.external.uri)} 183 </Text> 184 </View> 185 </View> 186 <Button 187 label={_(msg`Watch now`)} 188 size={platform({native: 'large', web: 'small'})} 189 color="primary" 190 variant="solid" 191 onPress={() => { 192 ax.metric('live:card:watch', {subject: profile.did}) 193 openLink(embed.external.uri, false) 194 }}> 195 <ButtonText> 196 <Trans>Watch now</Trans> 197 </ButtonText> 198 <ButtonIcon icon={SquareArrowTopRightIcon} /> 199 </Button> 200 <View style={[t.atoms.border_contrast_low, a.border_t, a.w_full]} /> 201 {moderationOpts && ( 202 <ProfileCard.Header> 203 <ProfileCard.Avatar 204 profile={profile} 205 moderationOpts={moderationOpts} 206 disabledPreview 207 /> 208 {/* Ensure wide enough on web hover */} 209 <View style={[a.flex_1, web({minWidth: 100})]}> 210 <ProfileCard.NameAndHandle 211 profile={profile} 212 moderationOpts={moderationOpts} 213 /> 214 </View> 215 <Button 216 label={ 217 onPressViewAvatar ? _(msg`View avatar`) : _(msg`Open profile`) 218 } 219 size="small" 220 color="secondary" 221 variant="solid" 222 onPress={() => { 223 if (onPressViewAvatar) { 224 ax.metric('live:card:viewAvatar', {subject: profile.did}) 225 onPressViewAvatar() 226 } else { 227 ax.metric('live:card:openProfile', {subject: profile.did}) 228 unstableCacheProfileView(queryClient, profile) 229 onPressOpenProfile() 230 } 231 }}> 232 <ButtonText> 233 {onPressViewAvatar ? ( 234 <Trans>View avatar</Trans> 235 ) : ( 236 <Trans>Open profile</Trans> 237 )} 238 </ButtonText> 239 </Button> 240 </ProfileCard.Header> 241 )} 242 <View 243 style={[ 244 a.flex_row, 245 a.align_center, 246 a.justify_between, 247 a.w_full, 248 a.pt_sm, 249 ]}> 250 <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> 251 <CircleInfoIcon size="sm" fill={t.atoms.text_contrast_low.color} /> 252 <Text style={[t.atoms.text_contrast_low, a.text_sm]}> 253 <Trans>Live feature is in beta</Trans> 254 </Text> 255 </View> 256 {status && ( 257 <SimpleInlineLinkText 258 label={_(msg`Report this livestream`)} 259 {...createStaticClick(() => { 260 function open() { 261 reportDialogControl.open({ 262 subject: { 263 ...status, 264 $type: 'app.bsky.actor.defs#statusView', 265 }, 266 }) 267 } 268 if (dialogContext.isWithinDialog) { 269 dialogContext.close(open) 270 } else { 271 open() 272 } 273 })} 274 style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]}> 275 <Trans>Report</Trans> 276 </SimpleInlineLinkText> 277 )} 278 </View> 279 </View> 280 </> 281 ) 282}