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 * as ProfileCard from '#/components/ProfileCard'
21import {Text} from '#/components/Typography'
22import type * as bsky from '#/types/bsky'
23import {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe'
24import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight'
25import {LiveIndicator} from './LiveIndicator'
26
27export function LiveStatusDialog({
28 control,
29 profile,
30 embed,
31}: {
32 control: Dialog.DialogControlProps
33 profile: bsky.profile.AnyProfileView
34 status: AppBskyActorDefs.StatusView
35 embed: AppBskyEmbedExternal.View
36}) {
37 const navigation = useNavigation<NavigationProp>()
38 return (
39 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
40 <Dialog.Handle difference={!!embed.external.thumb} />
41 <DialogInner profile={profile} embed={embed} navigation={navigation} />
42 </Dialog.Outer>
43 )
44}
45
46function DialogInner({
47 profile,
48 embed,
49 navigation,
50}: {
51 profile: bsky.profile.AnyProfileView
52 embed: AppBskyEmbedExternal.View
53 navigation: NavigationProp
54}) {
55 const {_} = useLingui()
56 const control = Dialog.useDialogContext()
57
58 const onPressOpenProfile = useCallback(() => {
59 control.close(() => {
60 navigation.push('Profile', {
61 name: profile.handle,
62 })
63 })
64 }, [navigation, profile.handle, control])
65
66 return (
67 <Dialog.ScrollableInner
68 label={_(msg`${sanitizeHandle(profile.handle)} is live`)}
69 contentContainerStyle={[a.pt_0, a.px_0]}
70 style={[web({maxWidth: 420}), a.overflow_hidden]}>
71 <LiveStatus
72 profile={profile}
73 embed={embed}
74 onPressOpenProfile={onPressOpenProfile}
75 />
76 <Dialog.Close />
77 </Dialog.ScrollableInner>
78 )
79}
80
81export function LiveStatus({
82 profile,
83 embed,
84 padding = 'xl',
85 onPressOpenProfile,
86}: {
87 profile: bsky.profile.AnyProfileView
88 embed: AppBskyEmbedExternal.View
89 padding?: 'lg' | 'xl'
90 onPressOpenProfile: () => void
91}) {
92 const {_} = useLingui()
93 const t = useTheme()
94 const queryClient = useQueryClient()
95 const openLink = useOpenLink()
96 const moderationOpts = useModerationOpts()
97
98 return (
99 <>
100 {embed.external.thumb && (
101 <View
102 style={[
103 t.atoms.bg_contrast_25,
104 a.w_full,
105 a.aspect_card,
106 android([
107 a.overflow_hidden,
108 {
109 borderTopLeftRadius: a.rounded_md.borderRadius,
110 borderTopRightRadius: a.rounded_md.borderRadius,
111 },
112 ]),
113 ]}>
114 <Image
115 source={embed.external.thumb}
116 contentFit="cover"
117 style={[a.absolute, a.inset_0]}
118 accessibilityIgnoresInvertColors
119 />
120 <LiveIndicator
121 size="large"
122 style={[
123 a.absolute,
124 {top: tokens.space.lg, left: tokens.space.lg},
125 a.align_start,
126 ]}
127 />
128 </View>
129 )}
130 <View
131 style={[
132 a.gap_lg,
133 padding === 'xl'
134 ? [a.px_xl, !embed.external.thumb ? a.pt_2xl : a.pt_lg]
135 : a.p_lg,
136 ]}>
137 <View style={[a.w_full, a.justify_center, a.gap_2xs]}>
138 <Text
139 numberOfLines={3}
140 style={[a.leading_snug, a.font_semi_bold, a.text_xl]}>
141 {embed.external.title || embed.external.uri}
142 </Text>
143 <View style={[a.flex_row, a.align_center, a.gap_2xs]}>
144 <Globe_Stroke2_Corner0_Rounded
145 size="xs"
146 style={[t.atoms.text_contrast_medium]}
147 />
148 <Text
149 numberOfLines={1}
150 style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
151 {toNiceDomain(embed.external.uri)}
152 </Text>
153 </View>
154 </View>
155 <Button
156 label={_(msg`Watch now`)}
157 size={platform({native: 'large', web: 'small'})}
158 color="primary"
159 variant="solid"
160 onPress={() => {
161 logger.metric(
162 'live:card:watch',
163 {subject: profile.did},
164 {statsig: true},
165 )
166 openLink(embed.external.uri, false)
167 }}>
168 <ButtonText>
169 <Trans>Watch now</Trans>
170 </ButtonText>
171 <ButtonIcon icon={SquareArrowTopRightIcon} />
172 </Button>
173 <View style={[t.atoms.border_contrast_low, a.border_t, a.w_full]} />
174 {moderationOpts && (
175 <ProfileCard.Header>
176 <ProfileCard.Avatar
177 profile={profile}
178 moderationOpts={moderationOpts}
179 disabledPreview
180 />
181 {/* Ensure wide enough on web hover */}
182 <View style={[a.flex_1, web({minWidth: 100})]}>
183 <ProfileCard.NameAndHandle
184 profile={profile}
185 moderationOpts={moderationOpts}
186 />
187 </View>
188 <Button
189 label={_(msg`Open profile`)}
190 size="small"
191 color="secondary"
192 variant="solid"
193 onPress={() => {
194 logger.metric(
195 'live:card:openProfile',
196 {subject: profile.did},
197 {statsig: true},
198 )
199 unstableCacheProfileView(queryClient, profile)
200 onPressOpenProfile()
201 }}>
202 <ButtonText>
203 <Trans>Open profile</Trans>
204 </ButtonText>
205 </Button>
206 </ProfileCard.Header>
207 )}
208 <Text
209 style={[
210 a.w_full,
211 a.text_center,
212 t.atoms.text_contrast_low,
213 a.text_sm,
214 ]}>
215 <Trans>Live feature is in beta testing</Trans>
216 </Text>
217 </View>
218 </>
219 )
220}