Bluesky app fork with some witchin' additions 💫

Merge pull request #9823 from bluesky-social/app-1788

[APP-1788] update design for stacked avatars

authored by

Spence Pope and committed by
GitHub
830f305d 8a088e87

+84 -63
+1
assets/icons/person_filled_corner2_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867Zm0 10.133c-3.888 0-6.863 2.263-8.071 5.435-.346.906-.11 1.8.44 2.436.535.619 1.36.996 2.25.996h10.762c.89 0 1.716-.377 2.25-.996.55-.636.786-1.53.441-2.436-1.208-3.173-4.184-5.435-8.072-5.435Z"/></svg>
+79 -63
src/components/ProgressGuide/List.tsx
··· 1 - import {type StyleProp, View, type ViewStyle} from 'react-native' 1 + import {useState} from 'react' 2 + import { 3 + type LayoutChangeEvent, 4 + type StyleProp, 5 + View, 6 + type ViewStyle, 7 + } from 'react-native' 2 8 import {msg, Trans} from '@lingui/macro' 3 9 import {useLingui} from '@lingui/react' 4 10 ··· 11 17 import {UserAvatar} from '#/view/com/util/UserAvatar' 12 18 import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf' 13 19 import {Button, ButtonIcon} from '#/components/Button' 14 - import {Person_Stroke2_Corner2_Rounded as PersonIcon} from '#/components/icons/Person' 20 + import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person' 15 21 import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' 16 22 import {Text} from '#/components/Typography' 17 23 import type * as bsky from '#/types/bsky' ··· 51 57 a.flex_col, 52 58 a.gap_md, 53 59 a.rounded_md, 54 - t.atoms.bg_contrast_25, 60 + t.atoms.bg_contrast_50, 55 61 a.p_lg, 56 62 style, 57 63 ]}> ··· 81 87 a.justify_between, 82 88 a.gap_sm, 83 89 ] 84 - : a.flex_col, 85 - !inlineLayout && a.gap_md, 90 + : [a.flex_col, a.gap_md], 86 91 ]}> 87 92 <StackedAvatars follows={follows?.pages?.[0]?.follows} /> 88 93 <FollowDialog guide={guide} showArrow={inlineLayout} /> ··· 112 117 113 118 function StackedAvatars({follows}: {follows?: bsky.profile.AnyProfileView[]}) { 114 119 const t = useTheme() 115 - const {centerColumnOffset} = useLayoutBreakpoints() 120 + const [containerWidth, setContainerWidth] = useState(0) 116 121 117 - // Smaller avatars for narrower viewport 118 - const avatarSize = centerColumnOffset ? 30 : 37 119 - const overlap = centerColumnOffset ? 9 : 11 120 - const iconSize = centerColumnOffset ? 14 : 18 122 + const onLayout = (e: LayoutChangeEvent) => { 123 + setContainerWidth(e.nativeEvent.layout.width) 124 + } 121 125 122 - // Use actual follows count, not the guide's event counter 126 + // Overlap ratio (22% of avatar size) 127 + const overlapRatio = 0.22 128 + 129 + // Calculate avatar size to fill container width 130 + // Formula: containerWidth = avatarSize * count - overlap * (count - 1) 131 + // Where overlap = avatarSize * overlapRatio 132 + const visiblePortions = TOTAL_AVATARS - overlapRatio * (TOTAL_AVATARS - 1) 133 + const avatarSize = containerWidth > 0 ? containerWidth / visiblePortions : 0 134 + const overlap = avatarSize * overlapRatio 135 + const iconSize = avatarSize * 0.5 136 + 123 137 const followedAvatars = follows?.slice(0, TOTAL_AVATARS) ?? [] 124 138 const remainingSlots = TOTAL_AVATARS - followedAvatars.length 125 139 126 - // Total width calculation: first avatar + (remaining * visible portion) 127 - const totalWidth = avatarSize + (TOTAL_AVATARS - 1) * (avatarSize - overlap) 128 - 129 140 return ( 130 - <View style={[a.flex_row, a.self_start, {width: totalWidth}]}> 131 - {/* Show followed user avatars */} 132 - {followedAvatars.map((follow, i) => ( 133 - <View 134 - key={follow.did} 135 - style={[ 136 - a.rounded_full, 137 - { 138 - marginLeft: i === 0 ? 0 : -overlap, 139 - zIndex: TOTAL_AVATARS - i, 140 - borderWidth: 2, 141 - borderColor: t.atoms.bg_contrast_25.backgroundColor, 142 - }, 143 - ]}> 144 - <UserAvatar 145 - type="user" 146 - size={avatarSize - 4} 147 - avatar={follow.avatar} 148 - /> 149 - </View> 150 - ))} 151 - {/* Show placeholder avatars for remaining slots */} 152 - {Array(remainingSlots) 153 - .fill(0) 154 - .map((_, i) => ( 155 - <View 156 - key={`placeholder-${i}`} 157 - style={[ 158 - a.align_center, 159 - a.justify_center, 160 - a.rounded_full, 161 - t.atoms.bg_contrast_100, 162 - { 163 - width: avatarSize, 164 - height: avatarSize, 165 - marginLeft: 166 - followedAvatars.length === 0 && i === 0 ? 0 : -overlap, 167 - zIndex: TOTAL_AVATARS - followedAvatars.length - i, 168 - borderWidth: 2, 169 - borderColor: t.atoms.bg_contrast_25.backgroundColor, 170 - }, 171 - ]}> 172 - <PersonIcon 173 - width={iconSize} 174 - height={iconSize} 175 - fill={t.atoms.text_contrast_low.color} 176 - /> 177 - </View> 178 - ))} 141 + <View style={[a.flex_row, a.flex_1]} onLayout={onLayout}> 142 + {containerWidth > 0 && ( 143 + <> 144 + {/* Show followed user avatars */} 145 + {followedAvatars.map((follow, i) => ( 146 + <View 147 + key={follow.did} 148 + style={[ 149 + a.rounded_full, 150 + a.border, 151 + t.atoms.border_contrast_low, 152 + { 153 + marginLeft: i === 0 ? 0 : -overlap, 154 + zIndex: TOTAL_AVATARS - i, 155 + }, 156 + ]}> 157 + <UserAvatar 158 + type="user" 159 + size={avatarSize - 2} 160 + avatar={follow.avatar} 161 + noBorder 162 + /> 163 + </View> 164 + ))} 165 + {/* Show placeholder avatars for remaining slots */} 166 + {Array(remainingSlots) 167 + .fill(0) 168 + .map((_, i) => ( 169 + <View 170 + key={`placeholder-${i}`} 171 + style={[ 172 + a.align_center, 173 + a.justify_center, 174 + a.rounded_full, 175 + t.atoms.bg_contrast_300, 176 + a.border, 177 + t.atoms.border_contrast_low, 178 + { 179 + width: avatarSize, 180 + height: avatarSize, 181 + marginLeft: 182 + followedAvatars.length === 0 && i === 0 ? 0 : -overlap, 183 + zIndex: TOTAL_AVATARS - followedAvatars.length - i, 184 + }, 185 + ]}> 186 + <PersonIcon 187 + width={iconSize} 188 + height={iconSize} 189 + fill={t.atoms.bg_contrast_50.backgroundColor} 190 + /> 191 + </View> 192 + ))} 193 + </> 194 + )} 179 195 </View> 180 196 ) 181 197 }
+4
src/components/icons/Person.tsx
··· 36 36 export const PersonGroup_Stroke2_Corner2_Rounded = createSinglePathSVG({ 37 37 path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm7.301 9.7c-.836-2.6-2.88-3.503-4.575-3.111a1 1 0 0 1-.451-1.949c2.815-.651 5.81.966 6.93 4.448a2.49 2.49 0 0 1-.506 2.43A2.92 2.92 0 0 1 20 20h-2a1 1 0 1 1 0-2h2a.92.92 0 0 0 .69-.295.49.49 0 0 0 .112-.505ZM8 14c-1.865 0-3.878 1.274-4.681 4.151a.57.57 0 0 0 .132.55c.15.171.4.299.695.299h7.708a.93.93 0 0 0 .695-.299.57.57 0 0 0 .132-.55C11.878 15.274 9.865 14 8 14Zm0-2c2.87 0 5.594 1.98 6.607 5.613.53 1.9-1.09 3.387-2.753 3.387H4.146c-1.663 0-3.283-1.487-2.753-3.387C2.406 13.981 5.129 12 8 12Z', 38 38 }) 39 + 40 + export const Person_Filled_Corner2_Rounded = createSinglePathSVG({ 41 + path: 'M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867ZM12.233 12.133c-3.888 0-6.863 2.263-8.071 5.435-.346.906-.11 1.8.44 2.436.535.619 1.36.996 2.25.996h10.762c.89 0 1.716-.377 2.25-.996.55-.636.786-1.53.441-2.436-1.208-3.173-4.184-5.435-8.072-5.435Z', 42 + })