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