@oxyhq/bloom 0.8.6 → 0.9.1

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 +186 -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 +181 -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 +69 -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 +39 -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 +69 -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 +39 -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 +257 -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 +70 -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 +40 -0
@@ -0,0 +1,257 @@
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 (border) thickness around each avatar, in pixels. Matches the
16
+ * original Mention `AvatarStack` (a single hairline-ish 1px separator). */
17
+ const RING_WIDTH = 1;
18
+ /** Default horizontal overlap as a fraction of avatar size — the original
19
+ * stack pulled each avatar left by `size / 3`. */
20
+ const DEFAULT_OVERLAP_RATIO = 1 / 3;
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
+ // The ring is the thin separator drawn between overlapping avatars. It
57
+ // defaults to the page background so avatars read as cleanly punched out of
58
+ // the surface behind them — matching the original Mention stack, which used
59
+ // `theme.colors.background`, not a large `card`-colored ring cell.
60
+ const ring = ringColor ?? theme.colors.background;
61
+ const effectiveOverlap =
62
+ overlap ?? Math.round(size * DEFAULT_OVERLAP_RATIO);
63
+ // Each avatar after the first is pulled left by `overlap`. The cell carries a
64
+ // 1px border on each side, so the negative margin absorbs both borders to
65
+ // keep the visual overlap equal to `effectiveOverlap`.
66
+ const negativeMargin = -(effectiveOverlap + RING_WIDTH * 2);
67
+
68
+ const shown = useMemo(
69
+ () => items.slice(0, Math.max(0, max)),
70
+ [items, max],
71
+ );
72
+
73
+ const realTotal = total ?? items.length;
74
+ const overflow = Math.max(0, realTotal - shown.length);
75
+
76
+ // Each cell is a circular, clipped container with a 1px ring border. The inner
77
+ // Avatar is inset by the border on both sides so the image fills the circle
78
+ // exactly inside the ring.
79
+ const innerSize = size - RING_WIDTH * 2;
80
+ const cellStyle = useMemo((): ViewStyle => {
81
+ return {
82
+ width: size,
83
+ height: size,
84
+ borderRadius: size / 2,
85
+ borderWidth: RING_WIDTH,
86
+ borderColor: ring,
87
+ overflow: 'hidden',
88
+ alignItems: 'center',
89
+ justifyContent: 'center',
90
+ };
91
+ }, [size, ring]);
92
+
93
+ const overflowTextStyle = useMemo(
94
+ () => ({
95
+ color: '#FFFFFF',
96
+ fontSize: Math.max(fontSize._2xs, Math.round(size * 0.36)),
97
+ fontWeight: '600' as const,
98
+ }),
99
+ [size],
100
+ );
101
+
102
+ const interactive = typeof onPressItem === 'function';
103
+ const hoverable =
104
+ typeof hoverHandlers?.onHoverIn === 'function' ||
105
+ typeof hoverHandlers?.onHoverOut === 'function';
106
+
107
+ // Pressing the "+N" circle surfaces the first hidden member (if any), so
108
+ // consumers can route it to a full member list.
109
+ const firstHidden = items[shown.length];
110
+ const overflowOnPress =
111
+ interactive && firstHidden
112
+ ? () => onPressItem?.(firstHidden, shown.length)
113
+ : undefined;
114
+
115
+ return (
116
+ <View style={[styles.row, style]} testID={testID}>
117
+ {shown.map((item, index) => {
118
+ const name = getItemName(item);
119
+ const accessibilityLabel = item.username
120
+ ? `${name ?? item.username} (@${item.username})`
121
+ : name;
122
+
123
+ // The negative margin and stacking order live on the outermost
124
+ // row element so layout and hit-testing stay aligned with the visual
125
+ // overlap. Earlier siblings render on top of later ones.
126
+ const wrapperStyle: ViewStyle = {
127
+ ...(index > 0 && { marginLeft: negativeMargin }),
128
+ zIndex: shown.length - index,
129
+ };
130
+
131
+ const cell = (
132
+ <View
133
+ ref={
134
+ hoverHandlers?.registerCellRef
135
+ ? (node) => hoverHandlers.registerCellRef?.(index, node)
136
+ : undefined
137
+ }
138
+ collapsable={false}
139
+ style={cellStyle}
140
+ >
141
+ {/*
142
+ Route the item's avatar value through Avatar's `source` prop (not
143
+ `uri`): full URLs pass through directly, while resolver-handled ids
144
+ (e.g. Oxy file IDs) are resolved by the consumer's ImageResolver —
145
+ exactly like the original Mention stack. No `name` is passed, so a
146
+ missing avatar shows Avatar's neutral default placeholder rather
147
+ than a colored deterministic initial.
148
+ */}
149
+ <Avatar source={item.uri ?? undefined} size={innerSize} />
150
+ </View>
151
+ );
152
+
153
+ if (interactive || hoverable) {
154
+ return (
155
+ <Pressable
156
+ key={getItemKey(item, index)}
157
+ onPress={
158
+ interactive ? () => onPressItem?.(item, index) : undefined
159
+ }
160
+ onHoverIn={
161
+ hoverHandlers?.onHoverIn
162
+ ? () => hoverHandlers.onHoverIn?.(item, index)
163
+ : undefined
164
+ }
165
+ onHoverOut={
166
+ hoverHandlers?.onHoverOut
167
+ ? () => hoverHandlers.onHoverOut?.(item, index)
168
+ : undefined
169
+ }
170
+ accessibilityRole={interactive ? 'button' : undefined}
171
+ accessibilityLabel={accessibilityLabel}
172
+ style={wrapperStyle}
173
+ >
174
+ {cell}
175
+ </Pressable>
176
+ );
177
+ }
178
+
179
+ return (
180
+ <View key={getItemKey(item, index)} style={wrapperStyle}>
181
+ {cell}
182
+ </View>
183
+ );
184
+ })}
185
+
186
+ {overflow > 0 && (
187
+ <OverflowCircle
188
+ count={overflow}
189
+ cellStyle={cellStyle}
190
+ negativeMargin={shown.length > 0 ? negativeMargin : 0}
191
+ textStyle={overflowTextStyle}
192
+ onPress={overflowOnPress}
193
+ />
194
+ )}
195
+ </View>
196
+ );
197
+ };
198
+
199
+ function OverflowCircle({
200
+ count,
201
+ cellStyle,
202
+ negativeMargin,
203
+ textStyle,
204
+ onPress,
205
+ }: {
206
+ count: number;
207
+ cellStyle: ViewStyle;
208
+ negativeMargin: number;
209
+ textStyle: { color: string; fontSize: number; fontWeight: '600' };
210
+ onPress?: () => void;
211
+ }) {
212
+ const theme = useTheme();
213
+ // The count circle is anchored at the lowest stacking order so it tucks behind
214
+ // the last avatar's ring. The margin lives on the outer element to keep layout
215
+ // aligned with the overlap.
216
+ const wrapperStyle: ViewStyle = {
217
+ ...(negativeMargin !== 0 && { marginLeft: negativeMargin }),
218
+ zIndex: 0,
219
+ };
220
+
221
+ // A solid "+N" count circle: secondary-text-colored fill with white text,
222
+ // matching the original Mention `ResponsiveAvatarStack` count circle.
223
+ const circle = (
224
+ <View
225
+ style={[cellStyle, { backgroundColor: theme.colors.textSecondary }]}
226
+ >
227
+ <Text allowFontScaling={false} style={textStyle}>
228
+ +{count}
229
+ </Text>
230
+ </View>
231
+ );
232
+
233
+ if (onPress) {
234
+ return (
235
+ <Pressable
236
+ onPress={onPress}
237
+ accessibilityRole="button"
238
+ accessibilityLabel={`${count} more`}
239
+ style={wrapperStyle}
240
+ >
241
+ {circle}
242
+ </Pressable>
243
+ );
244
+ }
245
+
246
+ return <View style={wrapperStyle}>{circle}</View>;
247
+ }
248
+
249
+ const styles = StyleSheet.create({
250
+ row: {
251
+ flexDirection: 'row',
252
+ alignItems: 'center',
253
+ },
254
+ });
255
+
256
+ export const AvatarGroupBase = memo(AvatarGroupBaseComponent);
257
+ 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,70 @@
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` carries the
7
+ * avatar value (routed into Avatar's `source` prop); `name`/`displayName`/
8
+ * `username` are used for the hover card and accessibility only.
9
+ */
10
+ export interface AvatarGroupItem {
11
+ /** Stable identity used as a React key and passed back to handlers. */
12
+ id?: string;
13
+ /**
14
+ * Avatar value, routed into {@link Avatar}'s `source` prop. Accepts a full
15
+ * image URL OR a resolver-handled id (e.g. an Oxy file ID) — non-URL strings
16
+ * resolve through the consumer's ImageResolver. Null/undefined renders
17
+ * Avatar's neutral default placeholder.
18
+ */
19
+ uri?: string | null;
20
+ /** Name used for the hover card / accessibility when `displayName` is absent. */
21
+ name?: string;
22
+ /** Canonical, already-resolved display name (preferred over `name`). */
23
+ displayName?: string;
24
+ /** Handle without the leading `@`. */
25
+ username?: string;
26
+ }
27
+
28
+ export interface AvatarGroupProps {
29
+ /** Members to render, in stacking order (first item sits on top). */
30
+ items: AvatarGroupItem[];
31
+ /** Diameter of each avatar in pixels. */
32
+ size?: number;
33
+ /** Maximum number of avatars to render before collapsing into the overflow chip. */
34
+ max?: number;
35
+ /**
36
+ * Real total count of members. When provided and larger than the number of
37
+ * rendered avatars, the overflow chip shows `+ (total - shown)`. When omitted,
38
+ * overflow is derived from `items.length - shown`.
39
+ */
40
+ total?: number;
41
+ /**
42
+ * Horizontal overlap between adjacent avatars in pixels. Defaults to ~1/3 of
43
+ * `size`. Larger values pull avatars closer together.
44
+ */
45
+ overlap?: number;
46
+ /**
47
+ * Color of the thin ring/border drawn around each avatar so overlapping
48
+ * avatars separate cleanly. Defaults to the theme `background` color so the
49
+ * avatars read as punched out of the surface behind them.
50
+ */
51
+ ringColor?: string;
52
+ /**
53
+ * Per-avatar press handler. When provided, each avatar (and the overflow chip)
54
+ * becomes pressable. When omitted, the group is purely presentational.
55
+ */
56
+ onPressItem?: (item: AvatarGroupItem, index: number) => void;
57
+ /**
58
+ * Enables the hover card on web. Hovering an avatar reveals a
59
+ * {@link UserHoverCard} for that item. No-op on native.
60
+ */
61
+ hoverCard?: boolean;
62
+ /**
63
+ * Per-item action slot rendered inside the hover card — typically the app's
64
+ * SDK FollowButton. Only used when `hoverCard` is enabled (web).
65
+ */
66
+ renderItemAction?: (item: AvatarGroupItem, index: number) => ReactNode;
67
+ /** Container style override. */
68
+ style?: StyleProp<ViewStyle>;
69
+ testID?: string;
70
+ }
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 source={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';