Bluesky app fork with some witchin' additions 馃挮
at readme-update 277 lines 8.9 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 ax.metric('live:card:openProfile', {subject: profile.did}) 224 unstableCacheProfileView(queryClient, profile) 225 onPressOpenProfile() 226 }}> 227 <ButtonText> 228 {onPressViewAvatar ? ( 229 <Trans>View avatar</Trans> 230 ) : ( 231 <Trans>Open profile</Trans> 232 )} 233 </ButtonText> 234 </Button> 235 </ProfileCard.Header> 236 )} 237 <View 238 style={[ 239 a.flex_row, 240 a.align_center, 241 a.justify_between, 242 a.w_full, 243 a.pt_sm, 244 ]}> 245 <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> 246 <CircleInfoIcon size="sm" fill={t.atoms.text_contrast_low.color} /> 247 <Text style={[t.atoms.text_contrast_low, a.text_sm]}> 248 <Trans>Live feature is in beta</Trans> 249 </Text> 250 </View> 251 {status && ( 252 <SimpleInlineLinkText 253 label={_(msg`Report this livestream`)} 254 {...createStaticClick(() => { 255 function open() { 256 reportDialogControl.open({ 257 subject: { 258 ...status, 259 $type: 'app.bsky.actor.defs#statusView', 260 }, 261 }) 262 } 263 if (dialogContext.isWithinDialog) { 264 dialogContext.close(open) 265 } else { 266 open() 267 } 268 })} 269 style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]}> 270 <Trans>Report</Trans> 271 </SimpleInlineLinkText> 272 )} 273 </View> 274 </View> 275 </> 276 ) 277}