/** * Sources — where an answer came from, folded under a count. * * A cited answer usually cites more places than anyone wants listed above it, * so the list opens from a single line: "Used 6 sources". Folded is the resting * state, unlike `Reasoning`, because a citation list is a thing you go and * check rather than a thing you watch arrive. * * ```tsx * * * * {sources.map((source) => ( * * ))} * * * ``` * * ## Where the props come from * * With the AI SDK these are the `source-url` parts of an assistant message: * `part.url` is the `href` and `part.title` the `title`. A part carries no * title of its own when the model did not supply one, in which case the host * of the URL is a better label than the whole of it. */ import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode, } from 'react'; import { Linking, Pressable, View, type PressableProps, type ViewProps, } from 'react-native'; import Animated, { useAnimatedStyle, useReducedMotion, useSharedValue, withTiming, } from 'react-native-reanimated'; import { tv } from 'tailwind-variants'; import { ChevronDownIcon, LinkIcon } from '../../icons'; import { Collapse } from '../../primitives/collapse'; import { Text } from '../../primitives/text'; import { cn } from '../../utils/cn'; const sourcesVariants = tv({ slots: { root: 'w-full gap-2', trigger: 'flex-row items-center gap-2 py-0.5', triggerLabel: 'text-sm font-medium text-muted-foreground', content: 'gap-0.5', source: 'flex-row items-center gap-2 rounded-xl px-2 py-2 active:bg-accent', sourceTitle: 'flex-1 text-sm text-foreground', sourceHost: 'text-xs text-muted-foreground', }, }); interface SourcesContextValue { open: boolean; setOpen: (open: boolean) => void; } const SourcesContext = createContext(null); function useSources(component: string): SourcesContextValue { const context = useContext(SourcesContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } export interface SourcesProps extends Omit { className?: string; /** Controlled open state. */ open?: boolean; /** Initial state when uncontrolled. Folded, which is where a citation list belongs. */ defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; children?: ReactNode; } function SourcesRoot({ className, open: openProp, defaultOpen = false, onOpenChange, children, ...props }: SourcesProps) { const { root } = sourcesVariants(); const [internalOpen, setInternalOpen] = useState(defaultOpen); const isControlled = openProp !== undefined; const open = isControlled ? openProp : internalOpen; const setOpen = useCallback( (next: boolean) => { if (!isControlled) setInternalOpen(next); onOpenChange?.(next); }, [isControlled, onOpenChange] ); const context = useMemo(() => ({ open, setOpen }), [open, setOpen]); return ( {children} ); } SourcesRoot.displayName = 'Sources'; export interface SourcesTriggerProps extends Omit { className?: string; /** How many sources the list holds. Reads "Used 6 sources". */ count?: number; /** Replaces the whole row. */ children?: ReactNode; } function SourcesTrigger({ className, count = 0, children, onPress, ...props }: SourcesTriggerProps) { const { open, setOpen } = useSources('Sources.Trigger'); const { trigger, triggerLabel } = sourcesVariants(); const reducedMotion = useReducedMotion(); const progress = useSharedValue(open ? 1 : 0); useEffect(() => { progress.value = reducedMotion ? open ? 1 : 0 : withTiming(open ? 1 : 0, { duration: 180 }); }, [open, reducedMotion, progress]); const chevronStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${progress.value * 180}deg` }], })); return ( { onPress?.(event); setOpen(!open); }} className={cn(trigger(), className)} {...props} > {children ?? ( <> Used {count} {count === 1 ? 'source' : 'sources'} )} ); } export interface SourcesContentProps extends Omit { className?: string; children?: ReactNode; } function SourcesContent({ className, children, ...props }: SourcesContentProps) { const { open } = useSources('Sources.Content'); const { content } = sourcesVariants(); return ( {children} ); } export interface SourcesSourceProps extends Omit { className?: string; /** Where the row goes. Opened with the platform's own handler. */ href?: string; /** * What the row says. Falls back to the URL's host, which is a better label * than a hundred characters of path — and models often send no title at all. */ title?: string; /** Leading glyph. A link by default; a favicon suits a real citation list. */ icon?: ReactNode; children?: ReactNode; } /** One citation. */ function SourcesSource({ className, href, title, icon, children, onPress, ...props }: SourcesSourceProps) { const { source, sourceTitle, sourceHost } = sourcesVariants(); const host = useMemo(() => hostOf(href), [href]); return ( { onPress?.(event); // No `canOpenURL` guard: it needs the scheme declared up front on iOS, // and a source is always http(s), which is always openable. if (href) void Linking.openURL(href).catch(() => undefined); }} className={cn(source(), className)} {...props} > {children ?? ( <> {icon ?? } {title ?? host ?? href} {title && host ? ( {host} ) : null} )} ); } /** * The host of a URL, without a URL parser. * * `URL` exists in Hermes but throws on anything malformed, and a model's * citation is not a thing to trust with an exception. A label is worth having * even when the string it came from is not a URL at all. */ function hostOf(href: string | undefined): string | undefined { if (!href) return undefined; const match = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)/i.exec(href); return match?.[1]?.replace(/^www\./i, ''); } SourcesTrigger.displayName = 'Sources.Trigger'; SourcesContent.displayName = 'Sources.Content'; SourcesSource.displayName = 'Sources.Source'; export const Sources = Object.assign(SourcesRoot, { Trigger: SourcesTrigger, Content: SourcesContent, Source: SourcesSource, });