forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {memo, useCallback, useMemo, useState} from 'react'
2import {View} from 'react-native'
3import {
4 type AppBskyActorDefs,
5 type AppBskyLabelerDefs,
6 moderateProfile,
7 type ModerationOpts,
8 type RichText as RichTextAPI,
9} from '@atproto/api'
10import {msg, Plural, plural, Trans} from '@lingui/macro'
11import {useLingui} from '@lingui/react'
12
13// eslint-disable-next-line @typescript-eslint/no-unused-vars
14import {MAX_LABELERS} from '#/lib/constants'
15import {useHaptics} from '#/lib/haptics'
16import {isAppLabeler} from '#/lib/moderation'
17import {useProfileShadow} from '#/state/cache/profile-shadow'
18import {type Shadow} from '#/state/cache/types'
19import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
20import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
21import {usePreferencesQuery} from '#/state/queries/preferences'
22import {useRequireAuth, useSession} from '#/state/session'
23import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
24import {atoms as a, tokens, useTheme} from '#/alf'
25import {Button, ButtonText} from '#/components/Button'
26import {type DialogOuterProps, useDialogControl} from '#/components/Dialog'
27import {
28 Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
29 Heart2_Stroke2_Corner0_Rounded as Heart,
30} from '#/components/icons/Heart2'
31import {Link} from '#/components/Link'
32import * as Prompt from '#/components/Prompt'
33import {RichText} from '#/components/RichText'
34import * as Toast from '#/components/Toast'
35import {Text} from '#/components/Typography'
36import {useAnalytics} from '#/analytics'
37import {IS_IOS} from '#/env'
38import {ProfileHeaderDisplayName} from './DisplayName'
39import {EditProfileDialog} from './EditProfileDialog'
40import {ProfileHeaderHandle} from './Handle'
41import {ProfileHeaderMetrics} from './Metrics'
42import {ProfileHeaderShell} from './Shell'
43
44interface Props {
45 profile: AppBskyActorDefs.ProfileViewDetailed
46 labeler: AppBskyLabelerDefs.LabelerViewDetailed
47 descriptionRT: RichTextAPI | null
48 moderationOpts: ModerationOpts
49 hideBackButton?: boolean
50 isPlaceholderProfile?: boolean
51}
52
53let ProfileHeaderLabeler = ({
54 profile: profileUnshadowed,
55 labeler,
56 descriptionRT,
57 moderationOpts,
58 hideBackButton = false,
59 isPlaceholderProfile,
60}: Props): React.ReactNode => {
61 const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
62 useProfileShadow(profileUnshadowed)
63 const t = useTheme()
64 const ax = useAnalytics()
65 const {_} = useLingui()
66 const {currentAccount, hasSession} = useSession()
67 const playHaptic = useHaptics()
68 const isSelf = currentAccount?.did === profile.did
69
70 const moderation = useMemo(
71 () => moderateProfile(profile, moderationOpts),
72 [profile, moderationOpts],
73 )
74 const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
75 const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
76 useUnlikeMutation()
77 const [likeUri, setLikeUri] = useState(labeler.viewer?.like || '')
78 const [likeCount, setLikeCount] = useState(labeler.likeCount || 0)
79
80 const onToggleLiked = useCallback(async () => {
81 if (!labeler) {
82 return
83 }
84 try {
85 playHaptic()
86
87 if (likeUri) {
88 await unlikeMod({uri: likeUri})
89 setLikeCount(c => c - 1)
90 setLikeUri('')
91 } else {
92 const res = await likeMod({uri: labeler.uri, cid: labeler.cid})
93 setLikeCount(c => c + 1)
94 setLikeUri(res.uri)
95 }
96 } catch (e: any) {
97 Toast.show(
98 _(
99 msg`There was an issue contacting the server, please check your internet connection and try again.`,
100 ),
101 {type: 'error'},
102 )
103 ax.logger.error(`Failed to toggle labeler like`, {message: e.message})
104 }
105 }, [ax, labeler, playHaptic, likeUri, unlikeMod, likeMod, _])
106
107 return (
108 <ProfileHeaderShell
109 profile={profile}
110 moderation={moderation}
111 hideBackButton={hideBackButton}
112 isPlaceholderProfile={isPlaceholderProfile}>
113 <View
114 style={[a.px_lg, a.pt_md, a.pb_sm]}
115 pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
116 <View
117 style={[a.flex_row, a.justify_end, a.align_center, a.gap_xs, a.pb_lg]}
118 pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
119 <HeaderLabelerButtons profile={profile} />
120 </View>
121 <View style={[a.flex_col, a.gap_2xs, a.pt_2xs, a.pb_md]}>
122 <ProfileHeaderDisplayName profile={profile} moderation={moderation} />
123 <ProfileHeaderHandle profile={profile} />
124 </View>
125 {!isPlaceholderProfile && (
126 <>
127 {isSelf && <ProfileHeaderMetrics profile={profile} />}
128 {descriptionRT && !moderation.ui('profileView').blur ? (
129 <View pointerEvents="auto">
130 <RichText
131 testID="profileHeaderDescription"
132 style={[a.text_md]}
133 numberOfLines={15}
134 value={descriptionRT}
135 enableTags
136 authorHandle={profile.handle}
137 />
138 </View>
139 ) : undefined}
140 {!isAppLabeler(profile.did) && (
141 <View style={[a.flex_row, a.gap_xs, a.align_center, a.pt_lg]}>
142 <Button
143 testID="toggleLikeBtn"
144 size="small"
145 color="secondary"
146 shape="round"
147 label={_(msg`Like this labeler`)}
148 disabled={!hasSession || isLikePending || isUnlikePending}
149 onPress={onToggleLiked}>
150 {likeUri ? (
151 <HeartFilled fill={t.palette.negative_400} />
152 ) : (
153 <Heart fill={t.atoms.text_contrast_medium.color} />
154 )}
155 </Button>
156
157 {typeof likeCount === 'number' && (
158 <Link
159 to={{
160 screen: 'ProfileLabelerLikedBy',
161 params: {
162 name: labeler.creator.handle || labeler.creator.did,
163 },
164 }}
165 size="tiny"
166 label={_(
167 msg`Liked by ${plural(likeCount, {
168 one: '# user',
169 other: '# users',
170 })}`,
171 )}>
172 {({hovered, focused, pressed}) => (
173 <Text
174 style={[
175 a.font_semi_bold,
176 a.text_sm,
177 t.atoms.text_contrast_medium,
178 (hovered || focused || pressed) &&
179 t.atoms.text_contrast_high,
180 ]}>
181 <Trans>
182 Liked by{' '}
183 <Plural
184 value={likeCount}
185 one="# user"
186 other="# users"
187 />
188 </Trans>
189 </Text>
190 )}
191 </Link>
192 )}
193 </View>
194 )}
195 </>
196 )}
197 </View>
198 </ProfileHeaderShell>
199 )
200}
201ProfileHeaderLabeler = memo(ProfileHeaderLabeler)
202export {ProfileHeaderLabeler}
203
204/**
205 * Keep this in sync with the value of {@link MAX_LABELERS}
206 */
207function CantSubscribePrompt({
208 control,
209}: {
210 control: DialogOuterProps['control']
211}) {
212 const {_} = useLingui()
213 return (
214 <Prompt.Outer control={control}>
215 <Prompt.Content>
216 <Prompt.TitleText>Unable to subscribe</Prompt.TitleText>
217 <Prompt.DescriptionText>
218 <Trans>
219 We're sorry! You can only subscribe to twenty labelers, and you've
220 reached your limit of twenty.
221 </Trans>
222 </Prompt.DescriptionText>
223 </Prompt.Content>
224 <Prompt.Actions>
225 <Prompt.Action onPress={() => control.close()} cta={_(msg`OK`)} />
226 </Prompt.Actions>
227 </Prompt.Outer>
228 )
229}
230
231export function HeaderLabelerButtons({
232 profile,
233 minimal = false,
234}: {
235 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
236 /** disable the subscribe button */
237 minimal?: boolean
238}) {
239 const t = useTheme()
240 const ax = useAnalytics()
241 const {_} = useLingui()
242 const {currentAccount} = useSession()
243 const requireAuth = useRequireAuth()
244 const playHaptic = useHaptics()
245 const editProfileControl = useDialogControl()
246 const {data: preferences} = usePreferencesQuery()
247 const {
248 mutateAsync: toggleSubscription,
249 variables,
250 reset,
251 } = useLabelerSubscriptionMutation()
252 const isSubscribed =
253 variables?.subscribe ??
254 preferences?.moderationPrefs.labelers.find(l => l.did === profile.did)
255
256 const cantSubscribePrompt = Prompt.usePromptControl()
257
258 const isMe = currentAccount?.did === profile.did
259
260 const onPressSubscribe = () =>
261 requireAuth(async (): Promise<void> => {
262 playHaptic()
263 const subscribe = !isSubscribed
264
265 try {
266 await toggleSubscription({
267 did: profile.did,
268 subscribe,
269 })
270
271 ax.metric(
272 subscribe
273 ? 'moderation:subscribedToLabeler'
274 : 'moderation:unsubscribedFromLabeler',
275 {},
276 )
277 } catch (e: any) {
278 reset()
279 if (e.message === 'MAX_LABELERS') {
280 cantSubscribePrompt.open()
281 return
282 }
283 ax.logger.error(`Failed to subscribe to labeler`, {message: e.message})
284 }
285 })
286 return (
287 <>
288 {isMe ? (
289 <>
290 <Button
291 testID="profileHeaderEditProfileButton"
292 size="small"
293 color="secondary"
294 onPress={editProfileControl.open}
295 label={_(msg`Edit profile`)}
296 style={a.rounded_full}>
297 <ButtonText>
298 <Trans>Edit Profile</Trans>
299 </ButtonText>
300 </Button>
301 <EditProfileDialog profile={profile} control={editProfileControl} />
302 </>
303 ) : !isAppLabeler(profile.did) && !minimal ? (
304 // hidden in the minimal header, because it's not shadowed so the two buttons
305 // can get out of sync. if you want to reenable, you'll need to add shadowing
306 // to the subscribed state -sfn
307 <Button
308 testID="toggleSubscribeBtn"
309 label={
310 isSubscribed
311 ? _(msg`Unsubscribe from this labeler`)
312 : _(msg`Subscribe to this labeler`)
313 }
314 onPress={onPressSubscribe}>
315 {state => (
316 <View
317 style={[
318 {
319 paddingVertical: 9,
320 paddingHorizontal: 12,
321 borderRadius: 6,
322 gap: 6,
323 backgroundColor: isSubscribed
324 ? state.hovered || state.pressed
325 ? t.palette.contrast_50
326 : t.palette.contrast_25
327 : state.hovered || state.pressed
328 ? tokens.color.temp_purple_dark
329 : tokens.color.temp_purple,
330 },
331 ]}>
332 <Text
333 style={[
334 {
335 color: isSubscribed
336 ? t.palette.contrast_700
337 : t.palette.white,
338 },
339 a.font_semi_bold,
340 a.text_center,
341 a.leading_tight,
342 ]}>
343 {isSubscribed ? (
344 <Trans>Unsubscribe</Trans>
345 ) : (
346 <Trans>Subscribe to Labeler</Trans>
347 )}
348 </Text>
349 </View>
350 )}
351 </Button>
352 ) : null}
353 <ProfileMenu profile={profile} />
354
355 <CantSubscribePrompt control={cantSubscribePrompt} />
356 </>
357 )
358}