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