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 {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 control.close(onPressViewAvatar)
84 }, [control, onPressViewAvatar])
85
86 return (
87 <Dialog.ScrollableInner
88 label={_(msg`${sanitizeHandle(profile.handle)} is live`)}
89 contentContainerStyle={[a.pt_0, a.px_0]}
90 style={[web({maxWidth: 420}), a.overflow_hidden]}>
91 <LiveStatus
92 status={status}
93 profile={profile}
94 embed={embed}
95 onPressOpenProfile={onPressOpenProfile}
96 {...(onPressViewAvatar
97 ? {onPressViewAvatar: handlePressViewAvatar}
98 : {})}
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 if (onPressViewAvatar) {
224 ax.metric('live:card:viewAvatar', {subject: profile.did})
225 onPressViewAvatar()
226 } else {
227 ax.metric('live:card:openProfile', {subject: profile.did})
228 unstableCacheProfileView(queryClient, profile)
229 onPressOpenProfile()
230 }
231 }}>
232 <ButtonText>
233 {onPressViewAvatar ? (
234 <Trans>View avatar</Trans>
235 ) : (
236 <Trans>Open profile</Trans>
237 )}
238 </ButtonText>
239 </Button>
240 </ProfileCard.Header>
241 )}
242 <View
243 style={[
244 a.flex_row,
245 a.align_center,
246 a.justify_between,
247 a.w_full,
248 a.pt_sm,
249 ]}>
250 <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
251 <CircleInfoIcon size="sm" fill={t.atoms.text_contrast_low.color} />
252 <Text style={[t.atoms.text_contrast_low, a.text_sm]}>
253 <Trans>Live feature is in beta</Trans>
254 </Text>
255 </View>
256 {status && (
257 <SimpleInlineLinkText
258 label={_(msg`Report this livestream`)}
259 {...createStaticClick(() => {
260 function open() {
261 reportDialogControl.open({
262 subject: {
263 ...status,
264 $type: 'app.bsky.actor.defs#statusView',
265 },
266 })
267 }
268 if (dialogContext.isWithinDialog) {
269 dialogContext.close(open)
270 } else {
271 open()
272 }
273 })}
274 style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]}>
275 <Trans>Report</Trans>
276 </SimpleInlineLinkText>
277 )}
278 </View>
279 </View>
280 </>
281 )
282}