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, 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 {logger} from '#/logger'
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 {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
22import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog'
23import * as ProfileCard from '#/components/ProfileCard'
24import {Text} from '#/components/Typography'
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 {_} = useLingui()
121 const t = useTheme()
122 const queryClient = useQueryClient()
123 const openLink = useOpenLink()
124 const moderationOpts = useModerationOpts()
125 const reportDialogControl = useGlobalReportDialogControl()
126 const dialogContext = Dialog.useDialogContext()
127
128 return (
129 <>
130 {embed.external.thumb && (
131 <View
132 style={[
133 t.atoms.bg_contrast_25,
134 a.w_full,
135 a.aspect_card,
136 android([
137 a.overflow_hidden,
138 {
139 borderTopLeftRadius: a.rounded_md.borderRadius,
140 borderTopRightRadius: a.rounded_md.borderRadius,
141 },
142 ]),
143 ]}>
144 <Image
145 source={embed.external.thumb}
146 contentFit="cover"
147 style={[a.absolute, a.inset_0]}
148 accessibilityIgnoresInvertColors
149 />
150 <LiveIndicator
151 size="large"
152 style={[
153 a.absolute,
154 {top: tokens.space.lg, left: tokens.space.lg},
155 a.align_start,
156 ]}
157 />
158 </View>
159 )}
160 <View
161 style={[
162 a.gap_lg,
163 padding === 'xl'
164 ? [a.px_xl, !embed.external.thumb ? a.pt_2xl : a.pt_lg]
165 : a.p_lg,
166 ]}>
167 <View style={[a.w_full, a.justify_center, a.gap_2xs]}>
168 <Text
169 numberOfLines={3}
170 style={[a.leading_snug, a.font_semi_bold, a.text_xl]}>
171 {embed.external.title || embed.external.uri}
172 </Text>
173 <View style={[a.flex_row, a.align_center, a.gap_2xs]}>
174 <Globe_Stroke2_Corner0_Rounded
175 size="xs"
176 style={[t.atoms.text_contrast_medium]}
177 />
178 <Text
179 numberOfLines={1}
180 style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
181 {toNiceDomain(embed.external.uri)}
182 </Text>
183 </View>
184 </View>
185 <Button
186 label={_(msg`Watch now`)}
187 size={platform({native: 'large', web: 'small'})}
188 color="primary"
189 variant="solid"
190 onPress={() => {
191 logger.metric(
192 'live:card:watch',
193 {subject: profile.did},
194 {statsig: true},
195 )
196 openLink(embed.external.uri, false)
197 }}>
198 <ButtonText>
199 <Trans>Watch now</Trans>
200 </ButtonText>
201 <ButtonIcon icon={SquareArrowTopRightIcon} />
202 </Button>
203 <View style={[t.atoms.border_contrast_low, a.border_t, a.w_full]} />
204 {moderationOpts && (
205 <ProfileCard.Header>
206 <ProfileCard.Avatar
207 profile={profile}
208 moderationOpts={moderationOpts}
209 disabledPreview
210 />
211 {/* Ensure wide enough on web hover */}
212 <View style={[a.flex_1, web({minWidth: 100})]}>
213 <ProfileCard.NameAndHandle
214 profile={profile}
215 moderationOpts={moderationOpts}
216 />
217 </View>
218 <Button
219 label={
220 onPressViewAvatar ? _(msg`View avatar`) : _(msg`Open profile`)
221 }
222 size="small"
223 color="secondary"
224 variant="solid"
225 onPress={() => {
226 if (onPressViewAvatar) {
227 logger.metric(
228 'live:card:viewAvatar',
229 {subject: profile.did},
230 {statsig: true},
231 )
232 onPressViewAvatar()
233 } else {
234 logger.metric(
235 'live:card:openProfile',
236 {subject: profile.did},
237 {statsig: true},
238 )
239 unstableCacheProfileView(queryClient, profile)
240 onPressOpenProfile()
241 }
242 }}>
243 <ButtonText>
244 {onPressViewAvatar ? (
245 <Trans>View avatar</Trans>
246 ) : (
247 <Trans>Open profile</Trans>
248 )}
249 </ButtonText>
250 </Button>
251 </ProfileCard.Header>
252 )}
253 <View
254 style={[
255 a.flex_row,
256 a.align_center,
257 a.justify_between,
258 a.flex_1,
259 a.pt_sm,
260 ]}>
261 <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
262 <CircleInfoIcon size="sm" fill={t.atoms.text_contrast_low.color} />
263 <Text style={[t.atoms.text_contrast_low, a.text_sm]}>
264 <Trans>Live feature is in beta</Trans>
265 </Text>
266 </View>
267 {status && (
268 <SimpleInlineLinkText
269 label={_(msg`Report this livestream`)}
270 {...createStaticClick(() => {
271 function open() {
272 reportDialogControl.open({
273 subject: {
274 ...status,
275 $type: 'app.bsky.actor.defs#statusView',
276 },
277 })
278 }
279 if (dialogContext.isWithinDialog) {
280 dialogContext.close(open)
281 } else {
282 open()
283 }
284 })}
285 style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]}>
286 <Trans>Report</Trans>
287 </SimpleInlineLinkText>
288 )}
289 </View>
290 </View>
291 </>
292 )
293}