Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import React 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 = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
50 new Map(),
51 )
52
53 /*
54 * Results for `knownFollowers` are not sorted consistently, so when
55 * revalidating we can see a flash of this data updating. This cache prevents
56 * this happening for screens that remain in memory. When pushing a new
57 * screen, or once this one is popped, this cache is empty, so new data is
58 * displayed.
59 */
60 if (profile.viewer?.knownFollowers && !cache.current.has(profile.did)) {
61 cache.current.set(profile.did, profile.viewer.knownFollowers)
62 }
63
64 const cachedKnownFollowers = cache.current.get(profile.did)
65
66 if (cachedKnownFollowers && shouldShowKnownFollowers(cachedKnownFollowers)) {
67 return (
68 <KnownFollowersInner
69 profile={profile}
70 cachedKnownFollowers={cachedKnownFollowers}
71 moderationOpts={moderationOpts}
72 onLinkPress={onLinkPress}
73 minimal={minimal}
74 showIfEmpty={showIfEmpty}
75 />
76 )
77 }
78
79 return <EmptyFallback show={showIfEmpty} />
80}
81
82function KnownFollowersInner({
83 profile,
84 moderationOpts,
85 cachedKnownFollowers,
86 onLinkPress,
87 minimal,
88 showIfEmpty,
89}: {
90 profile: bsky.profile.AnyProfileView
91 moderationOpts: ModerationOpts
92 cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
93 onLinkPress?: LinkProps['onPress']
94 minimal?: boolean
95 showIfEmpty?: boolean
96}) {
97 const t = useTheme()
98 const {_} = useLingui()
99
100 const textStyle = [a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]
101
102 const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => {
103 const moderation = moderateProfile(f, moderationOpts)
104 return {
105 profile: {
106 ...f,
107 displayName: sanitizeDisplayName(
108 f.displayName || f.handle,
109 moderation.ui('displayName'),
110 ),
111 },
112 moderation,
113 }
114 })
115
116 // Does not have blocks applied. Always >= slices.length
117 const serverCount = cachedKnownFollowers.count
118
119 /*
120 * We check above too, but here for clarity and a reminder to _check for
121 * valid indices_
122 */
123 if (slice.length === 0) return <EmptyFallback show={showIfEmpty} />
124
125 const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE
126
127 return (
128 <Link
129 label={_(
130 msg`Press to view followers of this account that you also follow`,
131 )}
132 onPress={onLinkPress}
133 to={makeProfileLink(profile, 'known-followers')}
134 style={[
135 a.max_w_full,
136 a.flex_row,
137 minimal ? a.gap_sm : a.gap_md,
138 a.align_center,
139 {marginLeft: -AVI_BORDER},
140 ]}>
141 {({hovered, pressed}) => (
142 <>
143 <View
144 style={[
145 a.flex_row,
146 {
147 height: SIZE,
148 },
149 pressed && {
150 opacity: 0.5,
151 },
152 ]}>
153 {slice.map(({profile: prof, moderation}, i) => (
154 <View
155 key={prof.did}
156 style={[
157 a.rounded_full,
158 {
159 borderWidth: AVI_BORDER,
160 borderColor: t.atoms.bg.backgroundColor,
161 width: SIZE + AVI_BORDER * 2,
162 height: SIZE + AVI_BORDER * 2,
163 zIndex: AVI_BORDER - i,
164 marginLeft: i > 0 ? -8 : 0,
165 },
166 ]}>
167 <UserAvatar
168 size={SIZE}
169 avatar={prof.avatar}
170 moderation={moderation.ui('avatar')}
171 type={prof.associated?.labeler ? 'labeler' : 'user'}
172 noBorder
173 />
174 </View>
175 ))}
176 </View>
177
178 <Text
179 style={[
180 a.flex_shrink,
181 textStyle,
182 hovered && {
183 textDecorationLine: 'underline',
184 textDecorationColor: t.atoms.text_contrast_medium.color,
185 },
186 pressed && {
187 opacity: 0.5,
188 },
189 ]}
190 numberOfLines={2}>
191 {slice.length >= 2 ? (
192 // 2-n followers, including blocks
193 serverCount > 2 ? (
194 <Trans>
195 Followed by{' '}
196 <Text emoji key={slice[0].profile.did} style={textStyle}>
197 {slice[0].profile.displayName}
198 </Text>
199 ,{' '}
200 <Text emoji key={slice[1].profile.did} style={textStyle}>
201 {slice[1].profile.displayName}
202 </Text>
203 , and{' '}
204 <Plural
205 value={serverCount - 2}
206 one="# other"
207 other="# others"
208 />
209 </Trans> // only 2
210 ) : (
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}