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 {logger} from '#/logger'
18import {isIOS} from '#/platform/detection'
19import {useProfileShadow} from '#/state/cache/profile-shadow'
20import {type Shadow} from '#/state/cache/types'
21import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
22import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
23import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
24import {usePreferencesQuery} from '#/state/queries/preferences'
25import {useRequireAuth, useSession} from '#/state/session'
26import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
27import {atoms as a, tokens, useTheme} from '#/alf'
28import {Button, ButtonText} from '#/components/Button'
29import {type DialogOuterProps, useDialogControl} from '#/components/Dialog'
30import {
31 Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
32 Heart2_Stroke2_Corner0_Rounded as Heart,
33} from '#/components/icons/Heart2'
34import {Link} from '#/components/Link'
35import * as Prompt from '#/components/Prompt'
36import {RichText} from '#/components/RichText'
37import * as Toast from '#/components/Toast'
38import {Text} from '#/components/Typography'
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 {_} = useLingui()
66 const {currentAccount, hasSession} = useSession()
67 const playHaptic = useHaptics()
68 const isSelf = currentAccount?.did === profile.did
69
70 const enableSquareButtons = useEnableSquareButtons()
71
72 const moderation = useMemo(
73 () => moderateProfile(profile, moderationOpts),
74 [profile, moderationOpts],
75 )
76 const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
77 const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
78 useUnlikeMutation()
79 const [likeUri, setLikeUri] = useState(labeler.viewer?.like || '')
80 const [likeCount, setLikeCount] = useState(labeler.likeCount || 0)
81
82 const onToggleLiked = useCallback(async () => {
83 if (!labeler) {
84 return
85 }
86 try {
87 playHaptic()
88
89 if (likeUri) {
90 await unlikeMod({uri: likeUri})
91 setLikeCount(c => c - 1)
92 setLikeUri('')
93 } else {
94 const res = await likeMod({uri: labeler.uri, cid: labeler.cid})
95 setLikeCount(c => c + 1)
96 setLikeUri(res.uri)
97 }
98 } catch (e: any) {
99 Toast.show(
100 _(
101 msg`There was an issue contacting the server, please check your internet connection and try again.`,
102 ),
103 {type: 'error'},
104 )
105 logger.error(`Failed to toggle labeler like`, {message: e.message})
106 }
107 }, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _])
108
109 return (
110 <ProfileHeaderShell
111 profile={profile}
112 moderation={moderation}
113 hideBackButton={hideBackButton}
114 isPlaceholderProfile={isPlaceholderProfile}>
115 <View
116 style={[a.px_lg, a.pt_md, a.pb_sm]}
117 pointerEvents={isIOS ? 'auto' : 'box-none'}>
118 <View
119 style={[a.flex_row, a.justify_end, a.align_center, a.gap_xs, a.pb_lg]}
120 pointerEvents={isIOS ? 'auto' : 'box-none'}>
121 <HeaderLabelerButtons profile={profile} />
122 </View>
123 <View style={[a.flex_col, a.gap_2xs, a.pt_2xs, a.pb_md]}>
124 <ProfileHeaderDisplayName profile={profile} moderation={moderation} />
125 <ProfileHeaderHandle profile={profile} />
126 </View>
127 {!isPlaceholderProfile && (
128 <>
129 {isSelf && <ProfileHeaderMetrics profile={profile} />}
130 {descriptionRT && !moderation.ui('profileView').blur ? (
131 <View pointerEvents="auto">
132 <RichText
133 testID="profileHeaderDescription"
134 style={[a.text_md]}
135 numberOfLines={15}
136 value={descriptionRT}
137 enableTags
138 authorHandle={profile.handle}
139 />
140 </View>
141 ) : undefined}
142 {!isAppLabeler(profile.did) && (
143 <View style={[a.flex_row, a.gap_xs, a.align_center, a.pt_lg]}>
144 <Button
145 testID="toggleLikeBtn"
146 size="small"
147 color="secondary"
148 shape={enableSquareButtons ? 'square' : 'round'}
149 label={_(msg`Like this labeler`)}
150 disabled={!hasSession || isLikePending || isUnlikePending}
151 onPress={onToggleLiked}>
152 {likeUri ? (
153 <HeartFilled fill={t.palette.negative_400} />
154 ) : (
155 <Heart fill={t.atoms.text_contrast_medium.color} />
156 )}
157 </Button>
158
159 {typeof likeCount === 'number' && (
160 <Link
161 to={{
162 screen: 'ProfileLabelerLikedBy',
163 params: {
164 name: labeler.creator.handle || labeler.creator.did,
165 },
166 }}
167 size="tiny"
168 label={_(
169 msg`Liked by ${plural(likeCount, {
170 one: '# user',
171 other: '# users',
172 })}`,
173 )}>
174 {({hovered, focused, pressed}) => (
175 <Text
176 style={[
177 a.font_semi_bold,
178 a.text_sm,
179 t.atoms.text_contrast_medium,
180 (hovered || focused || pressed) &&
181 t.atoms.text_contrast_high,
182 ]}>
183 <Trans>
184 Liked by{' '}
185 <Plural
186 value={likeCount}
187 one="# user"
188 other="# users"
189 />
190 </Trans>
191 </Text>
192 )}
193 </Link>
194 )}
195 </View>
196 )}
197 </>
198 )}
199 </View>
200 </ProfileHeaderShell>
201 )
202}
203ProfileHeaderLabeler = memo(ProfileHeaderLabeler)
204export {ProfileHeaderLabeler}
205
206/**
207 * Keep this in sync with the value of {@link MAX_LABELERS}
208 */
209function CantSubscribePrompt({
210 control,
211}: {
212 control: DialogOuterProps['control']
213}) {
214 const {_} = useLingui()
215 return (
216 <Prompt.Outer control={control}>
217 <Prompt.TitleText>Unable to subscribe</Prompt.TitleText>
218 <Prompt.DescriptionText>
219 <Trans>
220 We're sorry! You can only subscribe to twenty labelers, and you've
221 reached your limit of twenty.
222 </Trans>
223 </Prompt.DescriptionText>
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 {_} = useLingui()
240 const t = useTheme()
241 const {currentAccount} = useSession()
242 const requireAuth = useRequireAuth()
243 const playHaptic = useHaptics()
244 const editProfileControl = useDialogControl()
245 const {data: preferences} = usePreferencesQuery()
246 const {
247 mutateAsync: toggleSubscription,
248 variables,
249 reset,
250 } = useLabelerSubscriptionMutation()
251 const isSubscribed =
252 variables?.subscribe ??
253 preferences?.moderationPrefs.labelers.find(l => l.did === profile.did)
254
255 const cantSubscribePrompt = Prompt.usePromptControl()
256
257 const isMe = currentAccount?.did === profile.did
258
259 const onPressSubscribe = () =>
260 requireAuth(async (): Promise<void> => {
261 playHaptic()
262 const subscribe = !isSubscribed
263
264 try {
265 await toggleSubscription({
266 did: profile.did,
267 subscribe,
268 })
269
270 logger.metric(
271 subscribe
272 ? 'moderation:subscribedToLabeler'
273 : 'moderation:unsubscribedFromLabeler',
274 {},
275 {statsig: true},
276 )
277 } catch (e: any) {
278 reset()
279 if (e.message === 'MAX_LABELERS') {
280 cantSubscribePrompt.open()
281 return
282 }
283 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}