{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "glass-avatar",
  "title": "Glass Avatar",
  "description": "A round photo that falls back to coloured initials, with a presence dot and stacked groups.",
  "registryDependencies": [
    "@glassyui-native/glass-provider"
  ],
  "files": [
    {
      "path": "registry/glassyui-native/glass-avatar.tsx",
      "content": "import {\n  Children,\n  createContext,\n  forwardRef,\n  isValidElement,\n  useCallback,\n  useContext,\n  useMemo,\n  useState,\n  type ReactNode,\n} from 'react';\nimport {\n  Animated,\n  Easing,\n  StyleSheet,\n  Text,\n  View,\n  type ImageSourcePropType,\n  type ViewProps,\n} from 'react-native';\nimport { useGlassTheme } from './glass-provider';\nimport { wrapPlainText } from './lib/plain-text';\nimport { GLASS_MOTION, type GlassTone } from './lib/tokens';\n\nexport type GlassAvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';\n\nexport type GlassAvatarStatus = 'online' | 'away' | 'busy' | 'offline';\n\nexport type GlassAvatarProps = Omit<ViewProps, 'children'> & {\n  /** Describes the image. Defaults to `name`. */\n  alt?: string;\n  /** Shown when there is no image or it fails to load. Defaults to the initials of `name`. */\n  fallback?: ReactNode;\n  /** Names the person. Its initials fill in for a missing image and pick the colour. */\n  name?: string;\n  size?: GlassAvatarSize;\n  /** A bundled image, as `require('./photo.jpg')` gives it. Use `src` for an address. */\n  source?: ImageSourcePropType;\n  /** The image's address. */\n  src?: string;\n  /** A presence dot on the corner. */\n  status?: GlassAvatarStatus;\n};\n\nconst SIZE = {\n  xs: { diameter: 24, dot: 7 },\n  sm: { diameter: 32, dot: 8 },\n  md: { diameter: 40, dot: 10 },\n  lg: { diameter: 56, dot: 14 },\n  xl: { diameter: 80, dot: 18 },\n} as const;\n\n// Initials sit on one of these gradients, picked by the name so a person keeps their colour.\nconst TONES = [\n  ['#2d6de9', '#1a4fc7'],\n  ['#b93f24', '#882c1a'],\n  ['#707075', '#56565b'],\n  ['#d0386f', '#a82458'],\n  ['#b9540f', '#9c4308'],\n  ['#1f8150', '#146c3c'],\n  ['#0e7d8b', '#0a6674'],\n  ['#747479', '#535359'],\n] as const;\n\n// A face without a name is plain gray.\nconst NAMELESS = ['#747479', '#535359'] as const;\n\n// The face's hairline rim and the light along its top.\nconst RIM = {\n  dark: 'inset 0px 0px 0px 1px rgba(255, 255, 255, 0.18), inset 0px 1px 0px rgba(255, 255, 255, 0.22)',\n  light: 'inset 0px 0px 0px 1px rgba(0, 0, 0, 0.1), inset 0px 1px 0px rgba(255, 255, 255, 0.3)',\n} as const;\n\n// The \"+N\" counter is a step brighter than a resting control.\nconst MORE_FILL = {\n  dark: 'rgba(245, 245, 247, 0.14)',\n  light: 'rgba(255, 255, 255, 0.95)',\n} as const;\n\nconst STATUS_TONE = {\n  away: 'orange',\n  busy: 'red',\n  offline: 'neutral',\n  online: 'green',\n} as const satisfies Record<GlassAvatarStatus, GlassTone>;\n\n// The web cuts the face away around the dot and under the next avatar. React Native has no masks,\n// so a ring in the surface colour draws that gap instead.\nconst GAP = 2;\n\nconst EASE_OUT = Easing.bezier(0.32, 0.72, 0, 1);\n\nconst GroupSizeContext = createContext<GlassAvatarSize | null>(null);\n\nfunction initialsOf(name: string) {\n  const words = name.trim().split(/\\s+/).filter(Boolean);\n\n  if (words.length === 0) return '';\n\n  const first = Array.from(words[0])[0] ?? '';\n  const last = words.length > 1 ? (Array.from(words[words.length - 1])[0] ?? '') : '';\n\n  return `${first}${last}`.toUpperCase();\n}\n\n// The same name always gets the same colour.\nfunction toneOf(name: string) {\n  let hash = 0;\n\n  for (const character of name) hash = (hash * 31 + (character.codePointAt(0) ?? 0)) >>> 0;\n\n  return TONES[hash % TONES.length];\n}\n\ntype FaceProps = {\n  fallback: ReactNode;\n  source: ImageSourcePropType | undefined;\n  tone: readonly [string, string];\n};\n\n/** The image once it loads, over the fallback until then or for good if it fails. */\nfunction Face({ fallback, source, tone }: FaceProps) {\n  const { preferences } = useGlassTheme();\n  const [status, setStatus] = useState<'loading' | 'loaded' | 'failed'>('loading');\n  const [arrival] = useState(() => new Animated.Value(0));\n\n  const handleLoad = useCallback(() => {\n    setStatus('loaded');\n\n    if (preferences.reduceMotion) {\n      arrival.setValue(1);\n\n      return;\n    }\n\n    Animated.timing(arrival, {\n      duration: 260,\n      easing: EASE_OUT,\n      toValue: 1,\n      useNativeDriver: GLASS_MOTION.nativeDriver,\n    }).start();\n  }, [arrival, preferences.reduceMotion]);\n\n  const handleError = useCallback(() => setStatus('failed'), []);\n\n  return (\n    <>\n      {status === 'loaded' ? null : (\n        <View\n          style={[\n            StyleSheet.absoluteFill,\n            styles.center,\n            {\n              backgroundColor: tone[0],\n              experimental_backgroundImage: `linear-gradient(145deg, ${tone[0]}, ${tone[1]})`,\n            },\n          ]}\n        >\n          {fallback}\n        </View>\n      )}\n      {source && status !== 'failed' ? (\n        <Animated.Image\n          accessibilityIgnoresInvertColors\n          onError={handleError}\n          onLoad={handleLoad}\n          resizeMode=\"cover\"\n          source={source}\n          style={[\n            StyleSheet.absoluteFill,\n            {\n              opacity: arrival,\n              transform: [\n                { scale: arrival.interpolate({ inputRange: [0, 1], outputRange: [1.06, 1] }) },\n              ],\n            },\n          ]}\n        />\n      ) : null}\n    </>\n  );\n}\n\n/** A round photo that falls back to coloured initials, with an optional presence dot. */\nexport const GlassAvatar = forwardRef<View, GlassAvatarProps>(\n  ({ alt, fallback, name, size = 'md', source, src, status, style, ...rest }, ref) => {\n    const { colors, preferences } = useGlassTheme();\n    const groupSize = useContext(GroupSizeContext);\n    const { diameter, dot } = SIZE[groupSize ?? size];\n    const image = useMemo(() => source ?? (src ? { uri: src } : undefined), [source, src]);\n    // A new image starts over, showing the fallback until it loads.\n    const imageKey = image ? JSON.stringify(image) : 'none';\n    const described = [alt ?? name, status].filter(Boolean).join(', ');\n    // The dot's centre sits on the circle's edge, at its lower trailing corner.\n    const center = diameter * 0.8536;\n\n    const content = wrapPlainText(fallback ?? (name ? initialsOf(name) : null), (text) => (\n      <Text style={[styles.initials, { fontSize: diameter * 0.38 }]}>{text}</Text>\n    ));\n\n    return (\n      <View\n        ref={ref}\n        accessibilityLabel={described || undefined}\n        accessibilityRole={described ? 'image' : undefined}\n        accessible={described.length > 0}\n        style={[{ height: diameter, width: diameter }, style]}\n        {...rest}\n      >\n        <View\n          accessibilityElementsHidden\n          importantForAccessibility=\"no-hide-descendants\"\n          style={[\n            styles.face,\n            { backgroundColor: colors.controlSurface, borderRadius: diameter / 2 },\n          ]}\n        >\n          <Face\n            key={imageKey}\n            fallback={content}\n            source={image}\n            tone={name ? toneOf(name) : NAMELESS}\n          />\n          <View\n            style={[\n              StyleSheet.absoluteFill,\n              styles.rim,\n              { borderRadius: diameter / 2, boxShadow: RIM[preferences.theme] },\n            ]}\n          />\n        </View>\n        {status ? (\n          <View\n            style={[\n              styles.dot,\n              {\n                backgroundColor: colors.tones[STATUS_TONE[status]],\n                borderColor: colors.opaqueSurface,\n                borderRadius: dot / 2 + GAP,\n                height: dot + GAP * 2,\n                start: center - dot / 2 - GAP,\n                top: center - dot / 2 - GAP,\n                width: dot + GAP * 2,\n              },\n            ]}\n          />\n        ) : null}\n      </View>\n    );\n  },\n);\n\nGlassAvatar.displayName = 'GlassAvatar';\n\nexport type GlassAvatarGroupProps = ViewProps & {\n  /** Shows this many avatars, then a \"+N\" for the rest. */\n  max?: number;\n  /** Sizes every avatar in the group. */\n  size?: GlassAvatarSize;\n};\n\n/** Avatars stacked edge to edge, each overlapping the one before it, then a \"+N\" for the rest. */\nexport const GlassAvatarGroup = forwardRef<View, GlassAvatarGroupProps>(\n  ({ children, max, size = 'md', style, ...rest }, ref) => {\n    const { colors, preferences } = useGlassTheme();\n    const items = Children.toArray(children);\n    const shown = max === undefined ? items : items.slice(0, Math.max(0, max));\n    const hidden = items.length - shown.length;\n    const { diameter } = SIZE[size];\n\n    // Each avatar wears the gap ring outside its own outline, so the ring takes no room.\n    const ring = {\n      borderColor: colors.opaqueSurface,\n      borderRadius: diameter / 2 + GAP,\n      borderWidth: GAP,\n      margin: -GAP,\n    };\n\n    const overlap = { marginStart: -diameter * 0.2 - GAP };\n\n    return (\n      <GroupSizeContext.Provider value={size}>\n        <View ref={ref} style={[styles.group, style]} {...rest}>\n          {shown.map((child, index) => (\n            <View\n              key={isValidElement(child) ? child.key : index}\n              style={[ring, index > 0 && overlap]}\n            >\n              {child}\n            </View>\n          ))}\n          {hidden > 0 ? (\n            <View\n              accessible\n              accessibilityLabel={`${hidden} more`}\n              accessibilityRole=\"image\"\n              style={[ring, shown.length > 0 && overlap]}\n            >\n              <View\n                style={[\n                  styles.face,\n                  styles.center,\n                  {\n                    backgroundColor: MORE_FILL[preferences.theme],\n                    borderRadius: diameter / 2,\n                    height: diameter,\n                    width: diameter,\n                  },\n                ]}\n              >\n                <Text\n                  style={[styles.more, { color: colors.foreground, fontSize: diameter * 0.32 }]}\n                >\n                  +{hidden}\n                </Text>\n                <View\n                  style={[\n                    StyleSheet.absoluteFill,\n                    styles.rim,\n                    { borderRadius: diameter / 2, boxShadow: RIM[preferences.theme] },\n                  ]}\n                />\n              </View>\n            </View>\n          ) : null}\n        </View>\n      </GroupSizeContext.Provider>\n    );\n  },\n);\n\nGlassAvatarGroup.displayName = 'GlassAvatarGroup';\n\nconst styles = StyleSheet.create({\n  center: { alignItems: 'center', justifyContent: 'center' },\n  dot: { borderWidth: GAP, pointerEvents: 'none', position: 'absolute' },\n  face: { height: '100%', overflow: 'hidden', width: '100%' },\n  group: { alignItems: 'center', flexDirection: 'row' },\n  initials: {\n    color: '#ffffff',\n    fontWeight: '600',\n    textShadowColor: 'rgba(0, 0, 0, 0.22)',\n    textShadowOffset: { height: 1, width: 0 },\n    textShadowRadius: 1,\n  },\n  more: { fontWeight: '600' },\n  rim: { pointerEvents: 'none' },\n});\n",
      "type": "registry:ui",
      "target": "components/glassyui/glass-avatar.tsx"
    },
    {
      "path": "registry/glassyui-native/lib/plain-text.ts",
      "content": "import { Children, isValidElement, type ReactNode } from 'react';\n\n/**\n * Sets plain text inside a Text, which React Native needs to render it. Content that is all text\n * becomes one Text; beside elements such as icons, each run of text gets its own.\n */\nexport function wrapPlainText(node: ReactNode, render: (text: ReactNode) => ReactNode) {\n  const parts = Children.toArray(node);\n\n  if (parts.length === 0) return null;\n\n  if (parts.every((part) => !isValidElement(part))) return render(node);\n\n  return Children.map(node, (part) => (isValidElement(part) ? part : render(part)));\n}\n\n/** The words in `node` when it is only text, to name a control for assistive technology. */\nexport function plainTextOf(node: ReactNode) {\n  const parts = Children.toArray(node);\n\n  return parts.length > 0 && parts.every((part) => !isValidElement(part))\n    ? parts.join('')\n    : undefined;\n}\n",
      "type": "registry:lib",
      "target": "components/glassyui/lib/plain-text.ts"
    }
  ],
  "type": "registry:ui"
}