Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 356 lines 12 kB view raw
1import {useCallback, useMemo} from 'react' 2import {View} from 'react-native' 3import {Image} from 'expo-image' 4import { 5 type AppBskyActorDefs, 6 type AppBskyEmbedExternal, 7 moderateStatus, 8} from '@atproto/api' 9import {Trans, useLingui} from '@lingui/react/macro' 10import {useNavigation} from '@react-navigation/native' 11import {useQueryClient} from '@tanstack/react-query' 12 13import {useOpenLink} from '#/lib/hooks/useOpenLink' 14import {type NavigationProp} from '#/lib/routes/types' 15import {sanitizeHandle} from '#/lib/strings/handles' 16import {toNiceDomain} from '#/lib/strings/url-helpers' 17import {useImageCdnHost} from '#/state/preferences' 18import {maybeModifyImageCdnHost} from '#/state/preferences/image-cdn-host' 19import {useModerationOpts} from '#/state/preferences/moderation-opts' 20import {unstableCacheProfileView} from '#/state/queries/profile' 21import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf' 22import {Button, ButtonIcon, ButtonText} from '#/components/Button' 23import * as Dialog from '#/components/Dialog' 24import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' 25import {Globe_Stroke2_Corner0_Rounded} from '#/components/icons/Globe' 26import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' 27import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '#/components/icons/SquareArrowTopRight' 28import {createStaticClick, SimpleInlineLinkText} from '#/components/Link' 29import * as Hider from '#/components/moderation/Hider' 30import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog' 31import * as ProfileCard from '#/components/ProfileCard' 32import {Text} from '#/components/Typography' 33import {useAnalytics} from '#/analytics' 34import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator' 35import type * as bsky from '#/types/bsky' 36 37export function LiveStatusDialog({ 38 control, 39 profile, 40 embed, 41 status, 42 onPressViewAvatar, 43}: { 44 control: Dialog.DialogControlProps 45 profile: bsky.profile.AnyProfileView 46 status: AppBskyActorDefs.StatusView 47 embed: AppBskyEmbedExternal.View 48 onPressViewAvatar?: () => void 49}) { 50 const navigation = useNavigation<NavigationProp>() 51 return ( 52 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 53 <Dialog.Handle difference={!!embed.external.thumb} /> 54 <DialogInner 55 status={status} 56 profile={profile} 57 embed={embed} 58 navigation={navigation} 59 onPressViewAvatar={onPressViewAvatar} 60 /> 61 </Dialog.Outer> 62 ) 63} 64 65function DialogInner({ 66 profile, 67 embed, 68 navigation, 69 status, 70 onPressViewAvatar, 71}: { 72 profile: bsky.profile.AnyProfileView 73 embed: AppBskyEmbedExternal.View 74 navigation: NavigationProp 75 status: AppBskyActorDefs.StatusView 76 onPressViewAvatar?: () => void 77}) { 78 const {t: l} = useLingui() 79 const control = Dialog.useDialogContext() 80 81 const onPressOpenProfile = useCallback(() => { 82 control.close(() => { 83 navigation.push('Profile', { 84 name: profile.handle, 85 }) 86 }) 87 }, [navigation, profile.handle, control]) 88 89 const handlePressViewAvatar = useCallback(() => { 90 control.close(onPressViewAvatar) 91 }, [control, onPressViewAvatar]) 92 93 return ( 94 <Dialog.ScrollableInner 95 label={l`${sanitizeHandle(profile.handle)} is live`} 96 contentContainerStyle={[a.pt_0, a.px_0]} 97 style={[web({maxWidth: 420}), a.overflow_hidden]}> 98 <LiveStatus 99 status={status} 100 profile={profile} 101 embed={embed} 102 onPressOpenProfile={onPressOpenProfile} 103 {...(onPressViewAvatar 104 ? {onPressViewAvatar: handlePressViewAvatar} 105 : {})} 106 /> 107 <Dialog.Close /> 108 </Dialog.ScrollableInner> 109 ) 110} 111 112export function LiveStatus({ 113 status, 114 profile, 115 embed, 116 padding = 'xl', 117 onPressOpenProfile, 118 onPressViewAvatar, 119}: { 120 status: AppBskyActorDefs.StatusView 121 profile: bsky.profile.AnyProfileView 122 embed: AppBskyEmbedExternal.View 123 padding?: 'lg' | 'xl' 124 onPressOpenProfile: () => void 125 onPressViewAvatar?: () => void 126}) { 127 const ax = useAnalytics() 128 const {t: l} = useLingui() 129 const t = useTheme() 130 const queryClient = useQueryClient() 131 const openLink = useOpenLink() 132 const imageCdnHost = useImageCdnHost() 133 const moderationOpts = useModerationOpts() 134 const reportDialogControl = useGlobalReportDialogControl() 135 const dialogContext = Dialog.useDialogContext() 136 const moderation = useMemo(() => { 137 if (!moderationOpts) return undefined 138 return moderateStatus(profile, moderationOpts) 139 }, [profile, moderationOpts]) 140 141 return ( 142 <> 143 {embed.external.thumb && ( 144 <Hider.Outer modui={moderation?.ui('contentMedia')}> 145 <Hider.Mask> 146 <ModeratedImage /> 147 </Hider.Mask> 148 <Hider.Content> 149 <View 150 style={[ 151 t.atoms.bg_contrast_25, 152 a.w_full, 153 a.aspect_card, 154 android([ 155 a.overflow_hidden, 156 { 157 borderTopLeftRadius: a.rounded_md.borderRadius, 158 borderTopRightRadius: a.rounded_md.borderRadius, 159 }, 160 ]), 161 ]}> 162 <Image 163 source={maybeModifyImageCdnHost( 164 embed.external.thumb, 165 imageCdnHost, 166 )} 167 contentFit="cover" 168 style={[a.absolute, a.inset_0]} 169 accessibilityIgnoresInvertColors 170 /> 171 <LiveIndicator 172 size="large" 173 style={[ 174 a.absolute, 175 {top: tokens.space.lg, left: tokens.space.lg}, 176 a.align_start, 177 ]} 178 /> 179 </View> 180 </Hider.Content> 181 </Hider.Outer> 182 )} 183 <View 184 style={[ 185 a.gap_lg, 186 padding === 'xl' 187 ? [a.px_xl, !embed.external.thumb ? a.pt_2xl : a.pt_lg] 188 : a.p_lg, 189 ]}> 190 <View style={[a.w_full, a.justify_center, a.gap_2xs]}> 191 <Text 192 numberOfLines={3} 193 style={[a.leading_snug, a.font_semi_bold, a.text_xl]}> 194 {embed.external.title || embed.external.uri} 195 </Text> 196 <View style={[a.flex_row, a.align_center, a.gap_2xs]}> 197 <Globe_Stroke2_Corner0_Rounded 198 size="xs" 199 style={[t.atoms.text_contrast_medium]} 200 /> 201 <Text 202 numberOfLines={1} 203 style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 204 {toNiceDomain(embed.external.uri)} 205 </Text> 206 </View> 207 </View> 208 <Button 209 label={l`Watch now`} 210 size={platform({native: 'large', web: 'small'})} 211 color="primary" 212 variant="solid" 213 onPress={() => { 214 ax.metric('live:card:watch', {subject: profile.did}) 215 openLink(embed.external.uri, false) 216 }}> 217 <ButtonText> 218 <Trans>Watch now</Trans> 219 </ButtonText> 220 <ButtonIcon icon={SquareArrowTopRightIcon} /> 221 </Button> 222 <View style={[t.atoms.border_contrast_low, a.border_t, a.w_full]} /> 223 {moderationOpts && ( 224 <ProfileCard.Header> 225 <ProfileCard.Avatar 226 profile={profile} 227 moderationOpts={moderationOpts} 228 disabledPreview 229 /> 230 {/* Ensure wide enough on web hover */} 231 <View style={[a.flex_1, web({minWidth: 100})]}> 232 <ProfileCard.NameAndHandle 233 profile={profile} 234 moderationOpts={moderationOpts} 235 /> 236 </View> 237 <Button 238 label={onPressViewAvatar ? l`View avatar` : l`Open profile`} 239 size="small" 240 color="secondary" 241 variant="solid" 242 onPress={() => { 243 if (onPressViewAvatar) { 244 ax.metric('live:card:viewAvatar', {subject: profile.did}) 245 onPressViewAvatar() 246 } else { 247 ax.metric('live:card:openProfile', {subject: profile.did}) 248 unstableCacheProfileView(queryClient, profile) 249 onPressOpenProfile() 250 } 251 }}> 252 <ButtonText> 253 {onPressViewAvatar ? ( 254 <Trans>View avatar</Trans> 255 ) : ( 256 <Trans>Open profile</Trans> 257 )} 258 </ButtonText> 259 </Button> 260 </ProfileCard.Header> 261 )} 262 <View 263 style={[ 264 a.flex_row, 265 a.align_center, 266 a.justify_between, 267 a.w_full, 268 a.pt_sm, 269 ]}> 270 <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> 271 <CircleInfoIcon size="sm" fill={t.atoms.text_contrast_low.color} /> 272 <Text style={[t.atoms.text_contrast_low, a.text_sm]}> 273 <Trans>Live feature is in beta</Trans> 274 </Text> 275 </View> 276 {status && ( 277 <SimpleInlineLinkText 278 label={l`Report this livestream`} 279 {...createStaticClick(() => { 280 function open() { 281 reportDialogControl.open({ 282 subject: { 283 ...status, 284 $type: 'app.bsky.actor.defs#statusView', 285 }, 286 }) 287 } 288 if (dialogContext.isWithinDialog) { 289 dialogContext.close(open) 290 } else { 291 open() 292 } 293 })} 294 style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]}> 295 <Trans>Report</Trans> 296 </SimpleInlineLinkText> 297 )} 298 </View> 299 </View> 300 </> 301 ) 302} 303 304function ModeratedImage() { 305 const t = useTheme() 306 const {t: l} = useLingui() 307 const hider = Hider.useHider() 308 309 return ( 310 <View 311 style={[ 312 a.p_lg, 313 a.py_xl, 314 a.align_center, 315 a.justify_center, 316 t.atoms.bg_contrast_25, 317 ]}> 318 <View style={[a.align_center, a.gap_sm, {maxWidth: 200}]}> 319 <ImageIcon size="lg" fill={t.atoms.text_contrast_medium.color} /> 320 <Text 321 style={[ 322 a.italic, 323 a.leading_snug, 324 a.text_center, 325 t.atoms.text_contrast_medium, 326 ]}> 327 {hider.meta.allowOverride ? ( 328 <Trans comment="Image has been moderated and user has the option of showing it temporarily"> 329 Image is hidden due to your moderation settings. 330 </Trans> 331 ) : ( 332 /* 333 * In practice, if `allowOverride` is false, we won't even allow this 334 * dialog to open. That is handled in 335 * `#/features/liveNow/index.tsx`. But for clarity, I've included 336 * this here. 337 */ 338 <Trans comment="Image has been moderated and is not visible to the user"> 339 Image is unavailable. 340 </Trans> 341 )} 342 </Text> 343 344 {hider.meta.allowOverride && ( 345 <SimpleInlineLinkText 346 label={l`Show anyway`} 347 {...createStaticClick(() => { 348 hider.setIsContentVisible(true) 349 })}> 350 <Trans>Show anyway</Trans> 351 </SimpleInlineLinkText> 352 )} 353 </View> 354 </View> 355 ) 356}