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