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