Files
bsky-social-app/src/components/AvatarBubbles.tsx
T

216 lines
5.3 KiB
TypeScript

import {useEffect} from 'react'
import {View} from 'react-native'
import Animated, {
Easing,
type SharedValue,
useAnimatedStyle,
useSharedValue,
withDelay,
withTiming,
} from 'react-native-reanimated'
import {moderateProfile} from '#/lib/moderation/subjects'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
import type * as bsky from '#/types/bsky'
type Layout = {
size: number
x: number
y: number
zIndex?: number
border?: boolean
}
export function AvatarBubbles({
animate = false,
profiles: allProfiles,
self = false,
size = 120,
count,
}: {
animate?: boolean
profiles: bsky.profile.AnyProfileView[]
/**
* By default, when there are more than 2 profiles, the current user is
* filtered out (so you don't see yourself among your own group's members).
* Set this to `true` for cases where every passed profile should appear,
* e.g. an invite preview where the owner is meaningful regardless of viewer.
*/
self?: boolean
size?: number
/**
* The true number of members, used to decide how many bubbles to render when
* it exceeds the number of `profiles` we have on hand (e.g. an invite preview
* that only carries a few member profiles for a much larger group). Slots
* without a profile render as placeholders. Defaults to `profiles.length`.
*/
count?: number
}) {
const {currentAccount} = useSession()
const profiles =
!self && allProfiles.length > 2
? allProfiles.filter(p => p.did !== currentAccount?.did)
: allProfiles
const bubbleCount = Math.max(profiles.length, count ?? 0)
const scale = size / 120
const marginOffset = size < 120 ? -2 : 0
const initialValue = animate ? 0 : 1
const p0 = useSharedValue(initialValue)
const p1 = useSharedValue(initialValue)
const p2 = useSharedValue(initialValue)
const p3 = useSharedValue(initialValue)
useEffect(() => {
if (!animate) return
const animateBubble = (p: SharedValue<number>, i: number) => {
p.set(0)
p.set(() =>
withDelay(
500 + i * 100,
withTiming(1, {
duration: 250,
easing: Easing.out(Easing.back(1.75)),
}),
),
)
}
animateBubble(p0, 0)
animateBubble(p1, 1)
animateBubble(p2, 2)
animateBubble(p3, 3)
}, [animate, p0, p1, p2, p3])
const scales = [p0, p1, p2, p3]
const layouts = getLayouts(bubbleCount)
return (
<Animated.View style={[a.p_2xs, {height: size, width: size}]}>
<View
style={{
marginTop: marginOffset,
marginLeft: marginOffset,
transform: [{scale}],
transformOrigin: 'top left',
}}>
{layouts.map((layout, i) => (
<AvatarBubble
key={i}
profile={profiles[i]}
scale={scales[i]}
size={layout.size}
x={layout.x}
y={layout.y}
zIndex={layout.zIndex}
includeProfileBorder={layout.border}
/>
))}
</View>
</Animated.View>
)
}
function AvatarBubble({
profile: profileUnshadowed,
scale,
size,
x,
y,
zIndex,
includeProfileBorder,
}: {
profile?: bsky.profile.AnyProfileView
scale: SharedValue<number>
size: number
x: number
y: number
zIndex?: number
includeProfileBorder?: boolean
}) {
const t = useTheme()
const animatedStyle = useAnimatedStyle(() => ({
transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}],
}))
const profile = useMaybeProfileShadow(profileUnshadowed)
const moderationOpts = useModerationOpts()
return (
<Animated.View
style={[
a.absolute,
a.rounded_full,
a.flex_grow_0,
includeProfileBorder && {
borderColor: t.atoms.text_inverted.color,
borderWidth: 2,
},
zIndex != null && {zIndex},
animatedStyle,
]}>
{profile && moderationOpts ? (
<UserAvatar
avatar={profile.avatar}
size={size}
type="user"
hideLiveBadge
noBorder
moderation={moderateProfile(profile, moderationOpts).ui('avatar')}
/>
) : (
<AvatarPlaceholder size={size} />
)}
</Animated.View>
)
}
function AvatarPlaceholder({size}: {size: number}) {
const t = useTheme()
return (
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
t.atoms.bg_contrast_200,
{width: size, height: size},
]}>
<PersonIcon
width={size * 0.5}
height={size * 0.5}
fill={t.atoms.text_inverted.color}
/>
</View>
)
}
function getLayouts(count: number): Layout[] {
if (count === 3) {
return [
{size: 68, x: -2, y: -2},
{size: 56, x: 38, y: 62},
{size: 46, x: 71, y: 18},
]
}
if (count >= 4) {
return [
{size: 68, x: -2, y: -2},
{size: 56, x: 60, y: 49},
{size: 42, x: 14, y: 74},
{size: 32, x: 72, y: 9},
]
}
return [
{size: 76, x: -2, y: -2, zIndex: 20, border: true},
{size: 76, x: 42, y: 42, zIndex: 10, border: true},
]
}