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