Bluesky app fork with some witchin' additions 馃挮
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}