@oxyhq/bloom 0.8.6 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/lib/commonjs/avatar-group/AvatarGroup.js +26 -0
  2. package/lib/commonjs/avatar-group/AvatarGroup.js.map +1 -0
  3. package/lib/commonjs/avatar-group/AvatarGroup.web.js +116 -0
  4. package/lib/commonjs/avatar-group/AvatarGroup.web.js.map +1 -0
  5. package/lib/commonjs/avatar-group/AvatarGroupBase.js +184 -0
  6. package/lib/commonjs/avatar-group/AvatarGroupBase.js.map +1 -0
  7. package/lib/commonjs/avatar-group/index.js +13 -0
  8. package/lib/commonjs/avatar-group/index.js.map +1 -0
  9. package/lib/commonjs/avatar-group/index.web.js +13 -0
  10. package/lib/commonjs/avatar-group/index.web.js.map +1 -0
  11. package/lib/commonjs/avatar-group/types.js +6 -0
  12. package/lib/commonjs/avatar-group/types.js.map +1 -0
  13. package/lib/commonjs/index.js +60 -44
  14. package/lib/commonjs/index.js.map +1 -1
  15. package/lib/commonjs/index.web.js +74 -58
  16. package/lib/commonjs/index.web.js.map +1 -1
  17. package/lib/commonjs/user-hover-card/UserHoverCard.js +193 -0
  18. package/lib/commonjs/user-hover-card/UserHoverCard.js.map +1 -0
  19. package/lib/commonjs/user-hover-card/index.js +13 -0
  20. package/lib/commonjs/user-hover-card/index.js.map +1 -0
  21. package/lib/commonjs/user-hover-card/types.js +6 -0
  22. package/lib/commonjs/user-hover-card/types.js.map +1 -0
  23. package/lib/module/avatar-group/AvatarGroup.js +21 -0
  24. package/lib/module/avatar-group/AvatarGroup.js.map +1 -0
  25. package/lib/module/avatar-group/AvatarGroup.web.js +111 -0
  26. package/lib/module/avatar-group/AvatarGroup.web.js.map +1 -0
  27. package/lib/module/avatar-group/AvatarGroupBase.js +179 -0
  28. package/lib/module/avatar-group/AvatarGroupBase.js.map +1 -0
  29. package/lib/module/avatar-group/index.js +4 -0
  30. package/lib/module/avatar-group/index.js.map +1 -0
  31. package/lib/module/avatar-group/index.web.js +4 -0
  32. package/lib/module/avatar-group/index.web.js.map +1 -0
  33. package/lib/module/avatar-group/types.js +4 -0
  34. package/lib/module/avatar-group/types.js.map +1 -0
  35. package/lib/module/index.js +2 -0
  36. package/lib/module/index.js.map +1 -1
  37. package/lib/module/index.web.js +2 -0
  38. package/lib/module/index.web.js.map +1 -1
  39. package/lib/module/user-hover-card/UserHoverCard.js +188 -0
  40. package/lib/module/user-hover-card/UserHoverCard.js.map +1 -0
  41. package/lib/module/user-hover-card/index.js +4 -0
  42. package/lib/module/user-hover-card/index.js.map +1 -0
  43. package/lib/module/user-hover-card/types.js +4 -0
  44. package/lib/module/user-hover-card/types.js.map +1 -0
  45. package/lib/typescript/commonjs/avatar-group/AvatarGroup.d.ts +4 -0
  46. package/lib/typescript/commonjs/avatar-group/AvatarGroup.d.ts.map +1 -0
  47. package/lib/typescript/commonjs/avatar-group/AvatarGroup.web.d.ts +4 -0
  48. package/lib/typescript/commonjs/avatar-group/AvatarGroup.web.d.ts.map +1 -0
  49. package/lib/typescript/commonjs/avatar-group/AvatarGroupBase.d.ts +16 -0
  50. package/lib/typescript/commonjs/avatar-group/AvatarGroupBase.d.ts.map +1 -0
  51. package/lib/typescript/commonjs/avatar-group/index.d.ts +3 -0
  52. package/lib/typescript/commonjs/avatar-group/index.d.ts.map +1 -0
  53. package/lib/typescript/commonjs/avatar-group/index.web.d.ts +3 -0
  54. package/lib/typescript/commonjs/avatar-group/index.web.d.ts.map +1 -0
  55. package/lib/typescript/commonjs/avatar-group/types.d.ts +63 -0
  56. package/lib/typescript/commonjs/avatar-group/types.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/index.d.ts +4 -0
  58. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/index.web.d.ts +4 -0
  60. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/user-hover-card/UserHoverCard.d.ts +4 -0
  62. package/lib/typescript/commonjs/user-hover-card/UserHoverCard.d.ts.map +1 -0
  63. package/lib/typescript/commonjs/user-hover-card/index.d.ts +3 -0
  64. package/lib/typescript/commonjs/user-hover-card/index.d.ts.map +1 -0
  65. package/lib/typescript/commonjs/user-hover-card/types.d.ts +37 -0
  66. package/lib/typescript/commonjs/user-hover-card/types.d.ts.map +1 -0
  67. package/lib/typescript/module/avatar-group/AvatarGroup.d.ts +4 -0
  68. package/lib/typescript/module/avatar-group/AvatarGroup.d.ts.map +1 -0
  69. package/lib/typescript/module/avatar-group/AvatarGroup.web.d.ts +4 -0
  70. package/lib/typescript/module/avatar-group/AvatarGroup.web.d.ts.map +1 -0
  71. package/lib/typescript/module/avatar-group/AvatarGroupBase.d.ts +16 -0
  72. package/lib/typescript/module/avatar-group/AvatarGroupBase.d.ts.map +1 -0
  73. package/lib/typescript/module/avatar-group/index.d.ts +3 -0
  74. package/lib/typescript/module/avatar-group/index.d.ts.map +1 -0
  75. package/lib/typescript/module/avatar-group/index.web.d.ts +3 -0
  76. package/lib/typescript/module/avatar-group/index.web.d.ts.map +1 -0
  77. package/lib/typescript/module/avatar-group/types.d.ts +63 -0
  78. package/lib/typescript/module/avatar-group/types.d.ts.map +1 -0
  79. package/lib/typescript/module/index.d.ts +4 -0
  80. package/lib/typescript/module/index.d.ts.map +1 -1
  81. package/lib/typescript/module/index.web.d.ts +4 -0
  82. package/lib/typescript/module/index.web.d.ts.map +1 -1
  83. package/lib/typescript/module/user-hover-card/UserHoverCard.d.ts +4 -0
  84. package/lib/typescript/module/user-hover-card/UserHoverCard.d.ts.map +1 -0
  85. package/lib/typescript/module/user-hover-card/index.d.ts +3 -0
  86. package/lib/typescript/module/user-hover-card/index.d.ts.map +1 -0
  87. package/lib/typescript/module/user-hover-card/types.d.ts +37 -0
  88. package/lib/typescript/module/user-hover-card/types.d.ts.map +1 -0
  89. package/package.json +30 -1
  90. package/src/avatar-group/AvatarGroup.stories.tsx +135 -0
  91. package/src/avatar-group/AvatarGroup.tsx +19 -0
  92. package/src/avatar-group/AvatarGroup.web.tsx +145 -0
  93. package/src/avatar-group/AvatarGroupBase.tsx +249 -0
  94. package/src/avatar-group/index.ts +2 -0
  95. package/src/avatar-group/index.web.tsx +2 -0
  96. package/src/avatar-group/types.ts +64 -0
  97. package/src/index.ts +4 -0
  98. package/src/index.web.ts +4 -0
  99. package/src/user-hover-card/UserHoverCard.stories.tsx +113 -0
  100. package/src/user-hover-card/UserHoverCard.tsx +204 -0
  101. package/src/user-hover-card/index.ts +2 -0
  102. package/src/user-hover-card/types.ts +38 -0
@@ -0,0 +1,249 @@
1
+ import React, { memo, useMemo } from 'react';
2
+ import {
3
+ Pressable,
4
+ StyleSheet,
5
+ Text,
6
+ View,
7
+ type ViewStyle,
8
+ } from 'react-native';
9
+
10
+ import { Avatar } from '../avatar';
11
+ import { useTheme } from '../theme/use-theme';
12
+ import { fontSize } from '../styles/tokens';
13
+ import type { AvatarGroupItem, AvatarGroupProps } from './types';
14
+
15
+ /** Ring thickness as a fraction of avatar size. */
16
+ const RING_RATIO = 0.08;
17
+ /** Minimum ring thickness so small avatars still separate cleanly. */
18
+ const MIN_RING = 2;
19
+ /** Default horizontal overlap as a fraction of avatar size. */
20
+ const DEFAULT_OVERLAP_RATIO = 0.35;
21
+
22
+ function getItemName(item: AvatarGroupItem): string | undefined {
23
+ return item.displayName ?? item.name ?? item.username;
24
+ }
25
+
26
+ function getItemKey(item: AvatarGroupItem, index: number): string {
27
+ return item.id ?? item.username ?? item.uri ?? `avatar-${index}`;
28
+ }
29
+
30
+ /** Hover callbacks injected by the web fork; undefined (no-op) on native. */
31
+ export interface AvatarGroupCellHoverHandlers {
32
+ onHoverIn?: (item: AvatarGroupItem, index: number) => void;
33
+ onHoverOut?: (item: AvatarGroupItem, index: number) => void;
34
+ /** Sets the measured anchor ref for the hovered cell (web only). */
35
+ registerCellRef?: (index: number, node: View | null) => void;
36
+ }
37
+
38
+ interface AvatarGroupBaseProps extends AvatarGroupProps {
39
+ hoverHandlers?: AvatarGroupCellHoverHandlers;
40
+ }
41
+
42
+ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
43
+ items,
44
+ size = 32,
45
+ max = 5,
46
+ total,
47
+ overlap,
48
+ ringColor,
49
+ onPressItem,
50
+ style,
51
+ testID,
52
+ hoverHandlers,
53
+ }) => {
54
+ const theme = useTheme();
55
+
56
+ const ring = ringColor ?? theme.colors.card;
57
+ const ringWidth = Math.max(MIN_RING, Math.round(size * RING_RATIO));
58
+ const effectiveOverlap =
59
+ overlap ?? Math.round(size * DEFAULT_OVERLAP_RATIO);
60
+ // Each avatar after the first is pulled left by `overlap`, but the ring adds
61
+ // visual width on both sides, so the negative margin accounts for the ring.
62
+ const negativeMargin = -(effectiveOverlap + ringWidth);
63
+
64
+ const shown = useMemo(
65
+ () => items.slice(0, Math.max(0, max)),
66
+ [items, max],
67
+ );
68
+
69
+ const realTotal = total ?? items.length;
70
+ const overflow = Math.max(0, realTotal - shown.length);
71
+
72
+ const cellStyle = useMemo((): ViewStyle => {
73
+ return {
74
+ width: size + ringWidth * 2,
75
+ height: size + ringWidth * 2,
76
+ borderRadius: (size + ringWidth * 2) / 2,
77
+ backgroundColor: ring,
78
+ alignItems: 'center',
79
+ justifyContent: 'center',
80
+ };
81
+ }, [size, ringWidth, ring]);
82
+
83
+ const overflowTextStyle = useMemo(
84
+ () => ({
85
+ color: theme.colors.textSecondary,
86
+ fontSize: Math.max(fontSize._2xs, Math.round(size * 0.4)),
87
+ fontWeight: '600' as const,
88
+ }),
89
+ [theme.colors.textSecondary, size],
90
+ );
91
+
92
+ const interactive = typeof onPressItem === 'function';
93
+ const hoverable =
94
+ typeof hoverHandlers?.onHoverIn === 'function' ||
95
+ typeof hoverHandlers?.onHoverOut === 'function';
96
+
97
+ // Pressing the "+N" chip surfaces the first hidden member (if any), so
98
+ // consumers can route it to a full member list.
99
+ const firstHidden = items[shown.length];
100
+ const overflowOnPress =
101
+ interactive && firstHidden
102
+ ? () => onPressItem?.(firstHidden, shown.length)
103
+ : undefined;
104
+
105
+ return (
106
+ <View style={[styles.row, style]} testID={testID}>
107
+ {shown.map((item, index) => {
108
+ const name = getItemName(item);
109
+ const accessibilityLabel = item.username
110
+ ? `${name ?? item.username} (@${item.username})`
111
+ : name;
112
+
113
+ // The negative margin and stacking order live on the outermost
114
+ // row element so layout and hit-testing stay aligned with the visual
115
+ // overlap. Earlier siblings render on top of later ones.
116
+ const wrapperStyle: ViewStyle = {
117
+ ...(index > 0 && { marginLeft: negativeMargin }),
118
+ zIndex: shown.length - index,
119
+ };
120
+
121
+ const cell = (
122
+ <View
123
+ ref={
124
+ hoverHandlers?.registerCellRef
125
+ ? (node) => hoverHandlers.registerCellRef?.(index, node)
126
+ : undefined
127
+ }
128
+ collapsable={false}
129
+ style={cellStyle}
130
+ >
131
+ <Avatar uri={item.uri ?? undefined} name={name} size={size} />
132
+ </View>
133
+ );
134
+
135
+ if (interactive || hoverable) {
136
+ return (
137
+ <Pressable
138
+ key={getItemKey(item, index)}
139
+ onPress={
140
+ interactive ? () => onPressItem?.(item, index) : undefined
141
+ }
142
+ onHoverIn={
143
+ hoverHandlers?.onHoverIn
144
+ ? () => hoverHandlers.onHoverIn?.(item, index)
145
+ : undefined
146
+ }
147
+ onHoverOut={
148
+ hoverHandlers?.onHoverOut
149
+ ? () => hoverHandlers.onHoverOut?.(item, index)
150
+ : undefined
151
+ }
152
+ accessibilityRole={interactive ? 'button' : undefined}
153
+ accessibilityLabel={accessibilityLabel}
154
+ style={wrapperStyle}
155
+ >
156
+ {cell}
157
+ </Pressable>
158
+ );
159
+ }
160
+
161
+ return (
162
+ <View key={getItemKey(item, index)} style={wrapperStyle}>
163
+ {cell}
164
+ </View>
165
+ );
166
+ })}
167
+
168
+ {overflow > 0 && (
169
+ <OverflowChip
170
+ count={overflow}
171
+ cellStyle={cellStyle}
172
+ negativeMargin={shown.length > 0 ? negativeMargin : 0}
173
+ textStyle={overflowTextStyle}
174
+ onPress={overflowOnPress}
175
+ />
176
+ )}
177
+ </View>
178
+ );
179
+ };
180
+
181
+ function OverflowChip({
182
+ count,
183
+ cellStyle,
184
+ negativeMargin,
185
+ textStyle,
186
+ onPress,
187
+ }: {
188
+ count: number;
189
+ cellStyle: ViewStyle;
190
+ negativeMargin: number;
191
+ textStyle: { color: string; fontSize: number; fontWeight: '600' };
192
+ onPress?: () => void;
193
+ }) {
194
+ const theme = useTheme();
195
+ // The chip is anchored at the lowest stacking order so it tucks behind the
196
+ // last avatar's ring. The margin lives on the outer element to keep layout
197
+ // aligned with the overlap.
198
+ const wrapperStyle: ViewStyle = {
199
+ ...(negativeMargin !== 0 && { marginLeft: negativeMargin }),
200
+ zIndex: 0,
201
+ };
202
+
203
+ const chip = (
204
+ <View style={[cellStyle, { backgroundColor: theme.colors.card }]}>
205
+ <View
206
+ style={[
207
+ styles.chipInner,
208
+ { backgroundColor: theme.colors.backgroundTertiary },
209
+ ]}
210
+ >
211
+ <Text allowFontScaling={false} style={textStyle}>
212
+ +{count}
213
+ </Text>
214
+ </View>
215
+ </View>
216
+ );
217
+
218
+ if (onPress) {
219
+ return (
220
+ <Pressable
221
+ onPress={onPress}
222
+ accessibilityRole="button"
223
+ accessibilityLabel={`${count} more`}
224
+ style={wrapperStyle}
225
+ >
226
+ {chip}
227
+ </Pressable>
228
+ );
229
+ }
230
+
231
+ return <View style={wrapperStyle}>{chip}</View>;
232
+ }
233
+
234
+ const styles = StyleSheet.create({
235
+ row: {
236
+ flexDirection: 'row',
237
+ alignItems: 'center',
238
+ },
239
+ chipInner: {
240
+ width: '100%',
241
+ height: '100%',
242
+ borderRadius: 999,
243
+ alignItems: 'center',
244
+ justifyContent: 'center',
245
+ },
246
+ });
247
+
248
+ export const AvatarGroupBase = memo(AvatarGroupBaseComponent);
249
+ AvatarGroupBase.displayName = 'AvatarGroupBase';
@@ -0,0 +1,2 @@
1
+ export { AvatarGroup } from './AvatarGroup';
2
+ export type { AvatarGroupProps, AvatarGroupItem } from './types';
@@ -0,0 +1,2 @@
1
+ export { AvatarGroup } from './AvatarGroup.web';
2
+ export type { AvatarGroupProps, AvatarGroupItem } from './types';
@@ -0,0 +1,64 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { StyleProp, ViewStyle } from 'react-native';
3
+
4
+ /**
5
+ * A single member of an {@link AvatarGroup}. Mirrors the minimal shape needed to
6
+ * render an {@link Avatar} plus drive the optional hover card. `uri` is the
7
+ * avatar image URL; `name`/`displayName` feed Avatar's deterministic
8
+ * name-based placeholder when no image resolves.
9
+ */
10
+ export interface AvatarGroupItem {
11
+ /** Stable identity used as a React key and passed back to handlers. */
12
+ id?: string;
13
+ /** Avatar image URL (null/undefined → name-based placeholder). */
14
+ uri?: string | null;
15
+ /** Fallback name used for the avatar placeholder when `displayName` is absent. */
16
+ name?: string;
17
+ /** Canonical, already-resolved display name (preferred over `name`). */
18
+ displayName?: string;
19
+ /** Handle without the leading `@`. */
20
+ username?: string;
21
+ }
22
+
23
+ export interface AvatarGroupProps {
24
+ /** Members to render, in stacking order (first item sits on top). */
25
+ items: AvatarGroupItem[];
26
+ /** Diameter of each avatar in pixels. */
27
+ size?: number;
28
+ /** Maximum number of avatars to render before collapsing into the overflow chip. */
29
+ max?: number;
30
+ /**
31
+ * Real total count of members. When provided and larger than the number of
32
+ * rendered avatars, the overflow chip shows `+ (total - shown)`. When omitted,
33
+ * overflow is derived from `items.length - shown`.
34
+ */
35
+ total?: number;
36
+ /**
37
+ * Horizontal overlap between adjacent avatars in pixels. Defaults to ~35% of
38
+ * `size`. Larger values pull avatars closer together.
39
+ */
40
+ overlap?: number;
41
+ /**
42
+ * Color of the ring/border drawn around each avatar so they separate cleanly.
43
+ * Defaults to the theme `card` surface color.
44
+ */
45
+ ringColor?: string;
46
+ /**
47
+ * Per-avatar press handler. When provided, each avatar (and the overflow chip)
48
+ * becomes pressable. When omitted, the group is purely presentational.
49
+ */
50
+ onPressItem?: (item: AvatarGroupItem, index: number) => void;
51
+ /**
52
+ * Enables the hover card on web. Hovering an avatar reveals a
53
+ * {@link UserHoverCard} for that item. No-op on native.
54
+ */
55
+ hoverCard?: boolean;
56
+ /**
57
+ * Per-item action slot rendered inside the hover card — typically the app's
58
+ * SDK FollowButton. Only used when `hoverCard` is enabled (web).
59
+ */
60
+ renderItemAction?: (item: AvatarGroupItem, index: number) => ReactNode;
61
+ /** Container style override. */
62
+ style?: StyleProp<ViewStyle>;
63
+ testID?: string;
64
+ }
package/src/index.ts CHANGED
@@ -49,6 +49,10 @@ export type {
49
49
  ErrorBoundaryFallbackContext,
50
50
  } from './error-boundary';
51
51
  export * from './avatar';
52
+ export { AvatarGroup } from './avatar-group';
53
+ export type { AvatarGroupProps, AvatarGroupItem } from './avatar-group';
54
+ export { UserHoverCard } from './user-hover-card';
55
+ export type { UserHoverCardProps, UserHoverCardStat } from './user-hover-card';
52
56
  export * from './loading';
53
57
  export * as PromptInput from './prompt-input';
54
58
  export * from './switch';
package/src/index.web.ts CHANGED
@@ -54,6 +54,10 @@ export type {
54
54
  ErrorBoundaryFallbackContext,
55
55
  } from './error-boundary';
56
56
  export * from './avatar';
57
+ export { AvatarGroup } from './avatar-group/index.web';
58
+ export type { AvatarGroupProps, AvatarGroupItem } from './avatar-group/index.web';
59
+ export { UserHoverCard } from './user-hover-card';
60
+ export type { UserHoverCardProps, UserHoverCardStat } from './user-hover-card';
57
61
  export * from './loading/index.web';
58
62
  export * as PromptInput from './prompt-input';
59
63
  export * from './switch';
@@ -0,0 +1,113 @@
1
+ import React from 'react';
2
+ import { Pressable, Text, View } from 'react-native';
3
+ import type { Meta, StoryObj } from '@storybook/react-vite';
4
+
5
+ import { UserHoverCard } from './UserHoverCard';
6
+
7
+ const SAMPLE_AVATAR =
8
+ 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200&h=200&fit=crop';
9
+
10
+ function DemoFollowButton() {
11
+ return (
12
+ <Pressable
13
+ style={{
14
+ paddingHorizontal: 14,
15
+ paddingVertical: 7,
16
+ borderRadius: 999,
17
+ backgroundColor: '#1A73E8',
18
+ }}
19
+ >
20
+ <Text style={{ color: '#fff', fontWeight: '600', fontSize: 13 }}>
21
+ Follow
22
+ </Text>
23
+ </Pressable>
24
+ );
25
+ }
26
+
27
+ const meta: Meta<typeof UserHoverCard> = {
28
+ title: 'Components/UserHoverCard',
29
+ component: UserHoverCard,
30
+ argTypes: {
31
+ verified: { control: 'boolean' },
32
+ },
33
+ };
34
+
35
+ export default meta;
36
+
37
+ type Story = StoryObj<typeof UserHoverCard>;
38
+
39
+ export const Basic: Story = {
40
+ args: {
41
+ avatar: SAMPLE_AVATAR,
42
+ displayName: 'Nate Isern',
43
+ username: 'nate',
44
+ bio: 'Building the Oxy ecosystem. Designer, engineer, and occasional rose grower.',
45
+ stats: [
46
+ { label: 'Following', value: '312' },
47
+ { label: 'Followers', value: '4.8K' },
48
+ ],
49
+ verified: true,
50
+ },
51
+ };
52
+
53
+ export const WithAction: Story = {
54
+ args: {
55
+ avatar: SAMPLE_AVATAR,
56
+ displayName: 'Ada Lovelace',
57
+ username: 'ada',
58
+ bio: 'Mathematician. Wrote the first algorithm intended for a machine.',
59
+ stats: [
60
+ { label: 'Following', value: '12' },
61
+ { label: 'Followers', value: '1.2M' },
62
+ ],
63
+ verified: true,
64
+ action: <DemoFollowButton />,
65
+ },
66
+ name: 'With action slot',
67
+ };
68
+
69
+ export const NoAction: Story = {
70
+ args: {
71
+ avatar: null,
72
+ displayName: 'Grace Hopper',
73
+ username: 'grace',
74
+ bio: 'Computer scientist and US Navy rear admiral.',
75
+ stats: [
76
+ { label: 'Following', value: '88' },
77
+ { label: 'Followers', value: '900K' },
78
+ ],
79
+ },
80
+ name: 'No action slot',
81
+ };
82
+
83
+ export const Minimal: Story = {
84
+ args: {
85
+ displayName: 'Anonymous',
86
+ },
87
+ name: 'Minimal (name only)',
88
+ };
89
+
90
+ export const Pressable_: Story = {
91
+ render: (args) => (
92
+ <View style={{ gap: 8 }}>
93
+ <UserHoverCard
94
+ {...args}
95
+ avatar={SAMPLE_AVATAR}
96
+ displayName="Linus Torvalds"
97
+ username="linus"
98
+ bio="Creator of Linux and Git."
99
+ stats={[
100
+ { label: 'Following', value: '3' },
101
+ { label: 'Followers', value: '2.1M' },
102
+ ]}
103
+ action={<DemoFollowButton />}
104
+ onPressProfile={() => {
105
+ // eslint-disable-next-line no-alert
106
+ if (typeof window !== 'undefined') window.alert('open profile');
107
+ }}
108
+ />
109
+ <Text>Click the identity area to open the profile.</Text>
110
+ </View>
111
+ ),
112
+ name: 'Pressable identity',
113
+ };
@@ -0,0 +1,204 @@
1
+ import React, { memo, useMemo } from 'react';
2
+ import {
3
+ Platform,
4
+ Pressable,
5
+ StyleSheet,
6
+ Text,
7
+ View,
8
+ type ViewStyle,
9
+ } from 'react-native';
10
+
11
+ import { Avatar } from '../avatar';
12
+ import { VerifiedCheck } from '../icons/VerifiedCheck';
13
+ import { useTheme } from '../theme/use-theme';
14
+ import { borderRadius, fontSize, space } from '../styles/tokens';
15
+ import type { UserHoverCardProps } from './types';
16
+
17
+ const AVATAR_SIZE = 48;
18
+ const VERIFIED_SIZE = 16;
19
+
20
+ const UserHoverCardComponent: React.FC<UserHoverCardProps> = ({
21
+ avatar,
22
+ displayName,
23
+ username,
24
+ bio,
25
+ stats,
26
+ verified = false,
27
+ onPressProfile,
28
+ action,
29
+ style,
30
+ testID,
31
+ }) => {
32
+ const theme = useTheme();
33
+
34
+ const cardStyle = useMemo((): ViewStyle => {
35
+ const base: ViewStyle = {
36
+ backgroundColor: theme.colors.card,
37
+ borderRadius: borderRadius.lg,
38
+ borderWidth: StyleSheet.hairlineWidth,
39
+ borderColor: theme.colors.border,
40
+ padding: space.lg,
41
+ width: 280,
42
+ };
43
+
44
+ if (Platform.OS === 'web') {
45
+ base.boxShadow = `0px 8px 24px ${theme.colors.shadow}`;
46
+ } else {
47
+ base.shadowColor = theme.colors.shadow;
48
+ base.shadowOffset = { width: 0, height: 8 };
49
+ base.shadowOpacity = 0.18;
50
+ base.shadowRadius = 16;
51
+ base.elevation = 8;
52
+ }
53
+
54
+ return base;
55
+ }, [theme]);
56
+
57
+ const identity = (
58
+ <View style={styles.identityRow}>
59
+ <Avatar uri={avatar ?? undefined} name={displayName} size={AVATAR_SIZE} />
60
+ <View style={styles.identityText}>
61
+ <View style={styles.nameRow}>
62
+ <Text
63
+ numberOfLines={1}
64
+ style={[styles.displayName, { color: theme.colors.text }]}
65
+ >
66
+ {displayName}
67
+ </Text>
68
+ {verified && (
69
+ <VerifiedCheck
70
+ size="sm"
71
+ fill={theme.colors.primary}
72
+ width={VERIFIED_SIZE}
73
+ height={VERIFIED_SIZE}
74
+ style={styles.verifiedBadge}
75
+ />
76
+ )}
77
+ </View>
78
+ {username ? (
79
+ <Text
80
+ numberOfLines={1}
81
+ style={[styles.username, { color: theme.colors.textSecondary }]}
82
+ >
83
+ @{username}
84
+ </Text>
85
+ ) : null}
86
+ </View>
87
+ </View>
88
+ );
89
+
90
+ return (
91
+ <View style={[cardStyle, style]} testID={testID}>
92
+ <View style={styles.header}>
93
+ {onPressProfile ? (
94
+ <Pressable
95
+ onPress={onPressProfile}
96
+ accessibilityRole="button"
97
+ accessibilityLabel={
98
+ username ? `${displayName} (@${username})` : displayName
99
+ }
100
+ style={styles.identityPressable}
101
+ >
102
+ {identity}
103
+ </Pressable>
104
+ ) : (
105
+ <View style={styles.identityPressable}>{identity}</View>
106
+ )}
107
+ {action != null ? <View style={styles.action}>{action}</View> : null}
108
+ </View>
109
+
110
+ {bio ? (
111
+ <Text
112
+ numberOfLines={3}
113
+ style={[styles.bio, { color: theme.colors.text }]}
114
+ >
115
+ {bio}
116
+ </Text>
117
+ ) : null}
118
+
119
+ {stats && stats.length > 0 ? (
120
+ <View style={styles.statsRow}>
121
+ {stats.map((stat) => (
122
+ <View key={stat.label} style={styles.stat}>
123
+ <Text style={[styles.statValue, { color: theme.colors.text }]}>
124
+ {stat.value}
125
+ </Text>
126
+ <Text
127
+ style={[styles.statLabel, { color: theme.colors.textSecondary }]}
128
+ >
129
+ {stat.label}
130
+ </Text>
131
+ </View>
132
+ ))}
133
+ </View>
134
+ ) : null}
135
+ </View>
136
+ );
137
+ };
138
+
139
+ const styles = StyleSheet.create({
140
+ header: {
141
+ flexDirection: 'row',
142
+ alignItems: 'flex-start',
143
+ justifyContent: 'space-between',
144
+ gap: space.sm,
145
+ },
146
+ identityPressable: {
147
+ flexShrink: 1,
148
+ flexGrow: 1,
149
+ },
150
+ identityRow: {
151
+ flexDirection: 'row',
152
+ alignItems: 'center',
153
+ gap: space.sm,
154
+ },
155
+ identityText: {
156
+ flexShrink: 1,
157
+ flexGrow: 1,
158
+ },
159
+ nameRow: {
160
+ flexDirection: 'row',
161
+ alignItems: 'center',
162
+ gap: space.xs,
163
+ },
164
+ displayName: {
165
+ fontSize: fontSize.lg,
166
+ fontWeight: '700',
167
+ flexShrink: 1,
168
+ },
169
+ verifiedBadge: {
170
+ flexShrink: 0,
171
+ },
172
+ username: {
173
+ fontSize: fontSize.sm,
174
+ marginTop: space._2xs,
175
+ },
176
+ action: {
177
+ flexShrink: 0,
178
+ },
179
+ bio: {
180
+ fontSize: fontSize.sm,
181
+ lineHeight: fontSize.sm * 1.4,
182
+ marginTop: space.md,
183
+ },
184
+ statsRow: {
185
+ flexDirection: 'row',
186
+ gap: space.lg,
187
+ marginTop: space.md,
188
+ },
189
+ stat: {
190
+ flexDirection: 'row',
191
+ alignItems: 'baseline',
192
+ gap: space.xs,
193
+ },
194
+ statValue: {
195
+ fontSize: fontSize.sm,
196
+ fontWeight: '700',
197
+ },
198
+ statLabel: {
199
+ fontSize: fontSize.sm,
200
+ },
201
+ });
202
+
203
+ export const UserHoverCard = memo(UserHoverCardComponent);
204
+ UserHoverCard.displayName = 'UserHoverCard';
@@ -0,0 +1,2 @@
1
+ export { UserHoverCard } from './UserHoverCard';
2
+ export type { UserHoverCardProps, UserHoverCardStat } from './types';
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { StyleProp, ViewStyle } from 'react-native';
3
+
4
+ /** A single labeled metric rendered in the {@link UserHoverCardProps.stats} row. */
5
+ export interface UserHoverCardStat {
6
+ /** Human-readable label, e.g. `"Followers"`. */
7
+ label: string;
8
+ /** Pre-formatted value, e.g. `"1.2K"` or `42`. */
9
+ value: string | number;
10
+ }
11
+
12
+ export interface UserHoverCardProps {
13
+ /**
14
+ * Avatar image source — a URL string, or null/undefined to fall back to the
15
+ * deterministic name-based placeholder rendered by {@link Avatar}.
16
+ */
17
+ avatar?: string | null;
18
+ /** Canonical, already-resolved display name (an API contract — render directly). */
19
+ displayName: string;
20
+ /** Handle without the leading `@` (the component prefixes it). */
21
+ username?: string;
22
+ /** Optional short biography; truncated to a few lines. */
23
+ bio?: string;
24
+ /** Optional metrics row (e.g. Following / Followers). */
25
+ stats?: UserHoverCardStat[];
26
+ /** Whether to show the verified badge next to the display name. */
27
+ verified?: boolean;
28
+ /** Makes the identity area (avatar + name + handle) pressable. */
29
+ onPressProfile?: () => void;
30
+ /**
31
+ * Slot for a consumer-injected action — typically the app's SDK FollowButton.
32
+ * Bloom does NOT implement follow logic; it only renders the card.
33
+ */
34
+ action?: ReactNode;
35
+ /** Container style override. */
36
+ style?: StyleProp<ViewStyle>;
37
+ testID?: string;
38
+ }