forked from
jollywhoppers.com/witchsky.app
fork
Configure Feed
Select the types of activity you want to include in your feed.
Bluesky app fork with some witchin' additions 馃挮
fork
Configure Feed
Select the types of activity you want to include in your feed.
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 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}