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