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