forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useRef} from 'react'
2import {View} from 'react-native'
3import {
4 type AppBskyActorDefs,
5 moderateProfile,
6 type ModerationOpts,
7} from '@atproto/api'
8import {msg} from '@lingui/core/macro'
9import {useLingui} from '@lingui/react'
10import {Plural, Trans} from '@lingui/react/macro'
11
12import {makeProfileLink} from '#/lib/routes/links'
13import {sanitizeDisplayName} from '#/lib/strings/display-names'
14import {UserAvatar} from '#/view/com/util/UserAvatar'
15import {atoms as a, useTheme} from '#/alf'
16import {Link, type LinkProps} from '#/components/Link'
17import {Text} from '#/components/Typography'
18import type * as bsky from '#/types/bsky'
19
20const AVI_SIZE = 30
21const AVI_SIZE_SMALL = 20
22const AVI_BORDER = 1
23
24/**
25 * Shared logic to determine if `KnownFollowers` should be shown.
26 *
27 * Checks the # of actual returned users instead of the `count` value, because
28 * `count` includes blocked users and `followers` does not.
29 */
30export function shouldShowKnownFollowers(
31 knownFollowers?: AppBskyActorDefs.KnownFollowers,
32) {
33 return knownFollowers && knownFollowers.followers.length > 0
34}
35
36export function KnownFollowers({
37 profile,
38 moderationOpts,
39 onLinkPress,
40 minimal,
41 showIfEmpty,
42}: {
43 profile: bsky.profile.AnyProfileView
44 moderationOpts: ModerationOpts
45 onLinkPress?: LinkProps['onPress']
46 minimal?: boolean
47 showIfEmpty?: boolean
48}) {
49 const cache = useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(new Map())
50
51 /*
52 * Results for `knownFollowers` are not sorted consistently, so when
53 * revalidating we can see a flash of this data updating. This cache prevents
54 * this happening for screens that remain in memory. When pushing a new
55 * screen, or once this one is popped, this cache is empty, so new data is
56 * displayed.
57 */
58 if (profile.viewer?.knownFollowers && !cache.current.has(profile.did)) {
59 cache.current.set(profile.did, profile.viewer.knownFollowers)
60 }
61
62 const cachedKnownFollowers = cache.current.get(profile.did)
63
64 if (cachedKnownFollowers && shouldShowKnownFollowers(cachedKnownFollowers)) {
65 return (
66 <KnownFollowersInner
67 profile={profile}
68 cachedKnownFollowers={cachedKnownFollowers}
69 moderationOpts={moderationOpts}
70 onLinkPress={onLinkPress}
71 minimal={minimal}
72 showIfEmpty={showIfEmpty}
73 />
74 )
75 }
76
77 return <EmptyFallback show={showIfEmpty} />
78}
79
80function KnownFollowersInner({
81 profile,
82 moderationOpts,
83 cachedKnownFollowers,
84 onLinkPress,
85 minimal,
86 showIfEmpty,
87}: {
88 profile: bsky.profile.AnyProfileView
89 moderationOpts: ModerationOpts
90 cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
91 onLinkPress?: LinkProps['onPress']
92 minimal?: boolean
93 showIfEmpty?: boolean
94}) {
95 const t = useTheme()
96 const {_} = useLingui()
97
98 const textStyle = [a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]
99
100 const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => {
101 const moderation = moderateProfile(f, moderationOpts)
102 return {
103 profile: {
104 ...f,
105 displayName: sanitizeDisplayName(
106 f.displayName || f.handle,
107 moderation.ui('displayName'),
108 ),
109 },
110 moderation,
111 }
112 })
113
114 // Does not have blocks applied. Always >= slices.length
115 const serverCount = cachedKnownFollowers.count
116
117 /*
118 * We check above too, but here for clarity and a reminder to _check for
119 * valid indices_
120 */
121 if (slice.length === 0) return <EmptyFallback show={showIfEmpty} />
122
123 const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE
124
125 return (
126 <Link
127 label={_(
128 msg`Press to view followers of this account that you also follow`,
129 )}
130 onPress={onLinkPress}
131 to={makeProfileLink(profile, 'known-followers')}
132 style={[
133 a.max_w_full,
134 a.flex_row,
135 minimal ? a.gap_sm : a.gap_md,
136 a.align_center,
137 {marginLeft: -AVI_BORDER},
138 ]}>
139 {({hovered, pressed}) => (
140 <>
141 <View
142 style={[
143 a.flex_row,
144 {
145 height: SIZE,
146 },
147 pressed && {
148 opacity: 0.5,
149 },
150 ]}>
151 {slice.map(({profile: prof, moderation}, i) => (
152 <View
153 key={prof.did}
154 style={[
155 a.rounded_full,
156 {
157 borderWidth: AVI_BORDER,
158 borderColor: t.atoms.bg.backgroundColor,
159 width: SIZE + AVI_BORDER * 2,
160 height: SIZE + AVI_BORDER * 2,
161 zIndex: AVI_BORDER - i,
162 marginLeft: i > 0 ? -8 : 0,
163 },
164 ]}>
165 <UserAvatar
166 size={SIZE}
167 avatar={prof.avatar}
168 moderation={moderation.ui('avatar')}
169 type={prof.associated?.labeler ? 'labeler' : 'user'}
170 noBorder
171 />
172 </View>
173 ))}
174 </View>
175
176 <Text
177 style={[
178 a.flex_shrink,
179 textStyle,
180 hovered && {
181 textDecorationLine: 'underline',
182 textDecorationColor: t.atoms.text_contrast_medium.color,
183 },
184 pressed && {
185 opacity: 0.5,
186 },
187 ]}
188 numberOfLines={2}>
189 {slice.length >= 2 ? (
190 // 2-n followers, including blocks
191 serverCount > 2 ? ( // only 2
192 <Trans>
193 Followed by{' '}
194 <Text emoji key={slice[0].profile.did} style={textStyle}>
195 {slice[0].profile.displayName}
196 </Text>
197 ,{' '}
198 <Text emoji key={slice[1].profile.did} style={textStyle}>
199 {slice[1].profile.displayName}
200 </Text>
201 , and{' '}
202 <Plural
203 value={serverCount - 2}
204 one="# other"
205 other="# others"
206 />
207 </Trans>
208 ) : (
209 <Trans>
210 Followed by{' '}
211 <Text emoji key={slice[0].profile.did} style={textStyle}>
212 {slice[0].profile.displayName}
213 </Text>{' '}
214 and{' '}
215 <Text emoji key={slice[1].profile.did} style={textStyle}>
216 {slice[1].profile.displayName}
217 </Text>
218 </Trans>
219 )
220 ) : serverCount > 1 ? (
221 // 1-n followers, including blocks
222 <Trans>
223 Followed by{' '}
224 <Text emoji key={slice[0].profile.did} style={textStyle}>
225 {slice[0].profile.displayName}
226 </Text>{' '}
227 and{' '}
228 <Plural
229 value={serverCount - 1}
230 one="# other"
231 other="# others"
232 />
233 </Trans>
234 ) : (
235 // only 1
236 <Trans>
237 Followed by{' '}
238 <Text emoji key={slice[0].profile.did} style={textStyle}>
239 {slice[0].profile.displayName}
240 </Text>
241 </Trans>
242 )}
243 </Text>
244 </>
245 )}
246 </Link>
247 )
248}
249
250function EmptyFallback({show}: {show?: boolean}) {
251 const t = useTheme()
252
253 if (!show) return null
254
255 return (
256 <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
257 <Trans>Not followed by anyone you're following</Trans>
258 </Text>
259 )
260}