panelui-native 0.28.1 → 0.32.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 (121) hide show
  1. package/README.md +22 -3
  2. package/lib/module/components/calendar/index.js +450 -132
  3. package/lib/module/components/calendar/index.js.map +1 -1
  4. package/lib/module/components/code-block/highlight.js +363 -0
  5. package/lib/module/components/code-block/highlight.js.map +1 -0
  6. package/lib/module/components/code-block/index.js +313 -0
  7. package/lib/module/components/code-block/index.js.map +1 -0
  8. package/lib/module/components/date-picker/index.js +8 -1
  9. package/lib/module/components/date-picker/index.js.map +1 -1
  10. package/lib/module/components/heatmap-chart/index.js +9 -8
  11. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  12. package/lib/module/components/map/index.js +50 -4
  13. package/lib/module/components/map/index.js.map +1 -1
  14. package/lib/module/components/menu/index.js +714 -0
  15. package/lib/module/components/menu/index.js.map +1 -0
  16. package/lib/module/components/plan/index.js +582 -0
  17. package/lib/module/components/plan/index.js.map +1 -0
  18. package/lib/module/components/popover/index.js +35 -4
  19. package/lib/module/components/popover/index.js.map +1 -1
  20. package/lib/module/components/post/index.js +992 -0
  21. package/lib/module/components/post/index.js.map +1 -0
  22. package/lib/module/components/radio-group/index.js +13 -8
  23. package/lib/module/components/radio-group/index.js.map +1 -1
  24. package/lib/module/components/reasoning/index.js +244 -0
  25. package/lib/module/components/reasoning/index.js.map +1 -0
  26. package/lib/module/components/response/index.js +296 -0
  27. package/lib/module/components/response/index.js.map +1 -0
  28. package/lib/module/components/response/markdown.js +463 -0
  29. package/lib/module/components/response/markdown.js.map +1 -0
  30. package/lib/module/components/select/index.js +97 -15
  31. package/lib/module/components/select/index.js.map +1 -1
  32. package/lib/module/components/sources/index.js +221 -0
  33. package/lib/module/components/sources/index.js.map +1 -0
  34. package/lib/module/components/table/index.js +632 -0
  35. package/lib/module/components/table/index.js.map +1 -0
  36. package/lib/module/components/task/index.js +300 -0
  37. package/lib/module/components/task/index.js.map +1 -0
  38. package/lib/module/components/textarea/index.js +295 -0
  39. package/lib/module/components/textarea/index.js.map +1 -0
  40. package/lib/module/components/tooltip/index.js +179 -23
  41. package/lib/module/components/tooltip/index.js.map +1 -1
  42. package/lib/module/icons/index.js +339 -0
  43. package/lib/module/icons/index.js.map +1 -1
  44. package/lib/module/index.js +12 -1
  45. package/lib/module/index.js.map +1 -1
  46. package/lib/module/primitives/collapse.js +112 -0
  47. package/lib/module/primitives/collapse.js.map +1 -0
  48. package/lib/module/utils/date.js +129 -22
  49. package/lib/module/utils/date.js.map +1 -1
  50. package/lib/typescript/src/components/calendar/index.d.ts +79 -5
  51. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/code-block/highlight.d.ts +39 -0
  53. package/lib/typescript/src/components/code-block/highlight.d.ts.map +1 -0
  54. package/lib/typescript/src/components/code-block/index.d.ts +113 -0
  55. package/lib/typescript/src/components/code-block/index.d.ts.map +1 -0
  56. package/lib/typescript/src/components/date-picker/index.d.ts +7 -1
  57. package/lib/typescript/src/components/date-picker/index.d.ts.map +1 -1
  58. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  59. package/lib/typescript/src/components/map/index.d.ts +8 -1
  60. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  61. package/lib/typescript/src/components/menu/index.d.ts +261 -0
  62. package/lib/typescript/src/components/menu/index.d.ts.map +1 -0
  63. package/lib/typescript/src/components/plan/index.d.ts +238 -0
  64. package/lib/typescript/src/components/plan/index.d.ts.map +1 -0
  65. package/lib/typescript/src/components/popover/index.d.ts +35 -1
  66. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  67. package/lib/typescript/src/components/post/index.d.ts +358 -0
  68. package/lib/typescript/src/components/post/index.d.ts.map +1 -0
  69. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  70. package/lib/typescript/src/components/reasoning/index.d.ts +97 -0
  71. package/lib/typescript/src/components/reasoning/index.d.ts.map +1 -0
  72. package/lib/typescript/src/components/response/index.d.ts +91 -0
  73. package/lib/typescript/src/components/response/index.d.ts.map +1 -0
  74. package/lib/typescript/src/components/response/markdown.d.ts +88 -0
  75. package/lib/typescript/src/components/response/markdown.d.ts.map +1 -0
  76. package/lib/typescript/src/components/select/index.d.ts +27 -2
  77. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  78. package/lib/typescript/src/components/sources/index.d.ts +85 -0
  79. package/lib/typescript/src/components/sources/index.d.ts.map +1 -0
  80. package/lib/typescript/src/components/table/index.d.ts +274 -0
  81. package/lib/typescript/src/components/table/index.d.ts.map +1 -0
  82. package/lib/typescript/src/components/task/index.d.ts +103 -0
  83. package/lib/typescript/src/components/task/index.d.ts.map +1 -0
  84. package/lib/typescript/src/components/textarea/index.d.ts +175 -0
  85. package/lib/typescript/src/components/textarea/index.d.ts.map +1 -0
  86. package/lib/typescript/src/components/tooltip/index.d.ts +63 -8
  87. package/lib/typescript/src/components/tooltip/index.d.ts.map +1 -1
  88. package/lib/typescript/src/icons/index.d.ts +54 -1
  89. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  90. package/lib/typescript/src/index.d.ts +14 -3
  91. package/lib/typescript/src/index.d.ts.map +1 -1
  92. package/lib/typescript/src/primitives/collapse.d.ts +42 -0
  93. package/lib/typescript/src/primitives/collapse.d.ts.map +1 -0
  94. package/lib/typescript/src/utils/date.d.ts +49 -5
  95. package/lib/typescript/src/utils/date.d.ts.map +1 -1
  96. package/package.json +1 -1
  97. package/src/components/calendar/index.tsx +519 -139
  98. package/src/components/code-block/highlight.ts +278 -0
  99. package/src/components/code-block/index.tsx +341 -0
  100. package/src/components/date-picker/index.tsx +19 -1
  101. package/src/components/heatmap-chart/index.tsx +9 -9
  102. package/src/components/map/index.tsx +53 -4
  103. package/src/components/menu/index.tsx +812 -0
  104. package/src/components/plan/index.tsx +648 -0
  105. package/src/components/popover/index.tsx +69 -2
  106. package/src/components/post/index.tsx +1116 -0
  107. package/src/components/radio-group/index.tsx +11 -2
  108. package/src/components/reasoning/index.tsx +297 -0
  109. package/src/components/response/index.tsx +390 -0
  110. package/src/components/response/markdown.ts +489 -0
  111. package/src/components/select/index.tsx +132 -12
  112. package/src/components/sources/index.tsx +278 -0
  113. package/src/components/table/index.tsx +747 -0
  114. package/src/components/task/index.tsx +329 -0
  115. package/src/components/textarea/index.tsx +381 -0
  116. package/src/components/tooltip/index.tsx +203 -20
  117. package/src/icons/index.tsx +264 -1
  118. package/src/index.ts +131 -0
  119. package/src/primitives/collapse.tsx +128 -0
  120. package/src/utils/date.ts +146 -22
  121. package/theme.css +112 -0
@@ -0,0 +1,1116 @@
1
+ /**
2
+ * Post — a card carrying something somebody said, and what everyone did about it.
3
+ *
4
+ * Four shapes, because a feed is not one thing. `feed` is the full card: author,
5
+ * body, media and a row of counts. `vote` puts a score pill beside a headline
6
+ * and a thumbnail, the way a ranked community reads. `compact` drops the media
7
+ * and puts the name and handle on one line, for a dense timeline. `media` gives
8
+ * the image the whole card and lays the author over it.
9
+ *
10
+ * What they share is the anatomy — the same `Post.Header`, `Post.Body`,
11
+ * `Post.Footer` in every one — so moving between them is a prop rather than a
12
+ * rewrite. The variant decides padding, media shape and where the author sits;
13
+ * it does not decide which parts exist.
14
+ *
15
+ * ```tsx
16
+ * <Post variant="feed">
17
+ * <Post.Header>
18
+ * <Post.Author name="Dwayne F. White" verified timestamp="3m ago" avatar={face} />
19
+ * <Post.Action><EllipsisIcon /></Post.Action>
20
+ * </Post.Header>
21
+ * <Post.Body>I've been paying off my credit card #FinancialFreedom</Post.Body>
22
+ * <Post.Media source={photo} />
23
+ * <Post.Footer>
24
+ * <Post.Stat icon={EyeIcon} value="5,874" />
25
+ * <Post.Stat icon={HeartIcon} value="215" tone="like" />
26
+ * <Post.Stat icon={MessageCircleIcon} value="11" />
27
+ * <Post.Stat icon={BookmarkIcon} value="Save" align="end" />
28
+ * </Post.Footer>
29
+ * </Post>
30
+ * ```
31
+ *
32
+ * ## Counts change under the finger
33
+ *
34
+ * Every control here is a toggle over a number, and the number is the point: a
35
+ * like that lights up but leaves `215` sitting there has not told you it
36
+ * counted. So a stat that changes animates the old value out and the new one in
37
+ * along the direction of the change, and `Post.Votes` does the same with its
38
+ * arrows. The alternative — repainting the digits in place — is indisting-
39
+ * uishable from a re-render, which is exactly the doubt the animation exists to
40
+ * remove.
41
+ */
42
+ import {
43
+ createContext,
44
+ forwardRef,
45
+ useContext,
46
+ useEffect,
47
+ useMemo,
48
+ useRef,
49
+ type ComponentType,
50
+ type ReactNode,
51
+ } from 'react';
52
+ import {
53
+ Image,
54
+ Pressable,
55
+ View,
56
+ type ImageSourcePropType,
57
+ type PressableProps,
58
+ type ViewProps,
59
+ } from 'react-native';
60
+ import Animated, {
61
+ FadeIn,
62
+ FadeInDown,
63
+ FadeInUp,
64
+ FadeOut,
65
+ useAnimatedStyle,
66
+ useReducedMotion,
67
+ useSharedValue,
68
+ withSequence,
69
+ withSpring,
70
+ withTiming,
71
+ } from 'react-native-reanimated';
72
+ import { LinearGradient } from 'expo-linear-gradient';
73
+ import { tv, type VariantProps } from 'tailwind-variants';
74
+ import { useCSSVariable } from 'uniwind';
75
+ import {
76
+ ArrowDownIcon,
77
+ ArrowUpIcon,
78
+ BadgeCheckIcon,
79
+ type IconProps,
80
+ } from '../../icons';
81
+ import { usePrevious } from '../../hooks/use-previous';
82
+ import { AnimatedPressable } from '../../primitives/animated-pressable';
83
+ import { Text, textChildren } from '../../primitives/text';
84
+ import { cn } from '../../utils/cn';
85
+ import { selectionTick } from '../../utils/haptics';
86
+ import { Avatar } from '../avatar';
87
+
88
+ /** Long enough to see the value move, short enough to keep up with a fast tap. */
89
+ const COUNT_DURATION = 160;
90
+
91
+ /** The pop on a toggle: past 1 and back, so the press has a shape. */
92
+ const POP = { mass: 0.4, damping: 12, stiffness: 320 } as const;
93
+
94
+ const postVariants = tv({
95
+ slots: {
96
+ root: 'w-full overflow-hidden rounded-2xl border border-border bg-card',
97
+ header: 'flex-row items-start gap-3',
98
+ author: 'min-w-0 flex-1 gap-0.5',
99
+ authorLine: 'flex-row items-center gap-1.5',
100
+ name: 'font-semibold text-card-foreground',
101
+ handle: 'text-muted-foreground',
102
+ meta: 'text-sm text-muted-foreground',
103
+ // As tall as the name's own line box, so at `items-start` the menu lands on
104
+ // the username's line rather than somewhere between the two lines of the
105
+ // author block — and lands there identically in all four variants.
106
+ action: 'h-6 shrink-0 items-center justify-center',
107
+ // `flex-1` for the same reason `Post.Author` has it: it is the header's
108
+ // flexible side, and without it the row is only as wide as the group's
109
+ // name — so `Post.Action` sits against the name instead of at the edge.
110
+ community: 'min-w-0 h-6 flex-1 flex-row items-center gap-2',
111
+ communityName: 'text-xs font-semibold text-card-foreground',
112
+ communityMeta: 'text-xs text-muted-foreground',
113
+ title: 'text-base font-semibold leading-snug text-card-foreground',
114
+ body: 'text-card-foreground',
115
+ media: 'overflow-hidden bg-muted',
116
+ footer: 'flex-row items-center',
117
+ stat: 'flex-row items-center gap-1.5 rounded-full',
118
+ statLabel: 'text-sm text-muted-foreground',
119
+ votes: 'flex-row items-center gap-0.5 rounded-full bg-muted',
120
+ voteButton: 'h-8 w-8 items-center justify-center rounded-full',
121
+ voteScore: 'text-sm font-semibold tabular-nums',
122
+ },
123
+ variants: {
124
+ variant: {
125
+ /** The full card — author, body, media, counts. */
126
+ feed: {
127
+ header: 'p-4 pb-3',
128
+ body: 'px-4 pb-3 text-base leading-relaxed',
129
+ media: 'mx-4 rounded-xl',
130
+ footer: 'gap-5 p-4',
131
+ },
132
+ /** A headline with a score beside it, the way a ranked community reads. */
133
+ vote: {
134
+ header: 'px-4 pb-2 pt-3.5',
135
+ body: 'px-4 pb-3 text-sm leading-relaxed',
136
+ media: 'mx-4 rounded-xl',
137
+ footer: 'gap-2 px-4 pb-3.5',
138
+ },
139
+ /** No media, name and handle on one line — for a dense timeline. */
140
+ compact: {
141
+ root: 'rounded-xl',
142
+ header: 'p-3.5 pb-2',
143
+ body: 'px-3.5 pb-2.5 text-sm leading-relaxed',
144
+ media: 'mx-3.5 rounded-lg',
145
+ footer: 'gap-6 px-3.5 pb-3',
146
+ },
147
+ /** The image is the card; the author is laid over it. */
148
+ media: {
149
+ header: 'absolute inset-x-0 top-0 z-10 p-3',
150
+ body: 'px-4 pb-3 pt-3 text-sm leading-relaxed',
151
+ media: 'rounded-none',
152
+ footer: 'gap-5 px-4 pb-4',
153
+ },
154
+ },
155
+ size: {
156
+ default: {},
157
+ sm: { name: 'text-sm', meta: 'text-xs', statLabel: 'text-xs' },
158
+ },
159
+ },
160
+ defaultVariants: {
161
+ variant: 'feed',
162
+ size: 'default',
163
+ },
164
+ });
165
+
166
+ type PostVariant = 'feed' | 'vote' | 'compact' | 'media';
167
+
168
+ const PostContext = createContext<{ variant: PostVariant; size: 'default' | 'sm' }>({
169
+ variant: 'feed',
170
+ size: 'default',
171
+ });
172
+
173
+ function usePost() {
174
+ return useContext(PostContext);
175
+ }
176
+
177
+ export interface PostProps
178
+ extends Omit<ViewProps, 'children'>,
179
+ VariantProps<typeof postVariants> {
180
+ className?: string;
181
+ /** Which of the four shapes. */
182
+ variant?: PostVariant;
183
+ /** `sm` tightens the type for a card in a sidebar or a preview. */
184
+ size?: 'default' | 'sm';
185
+ /** Opening the post itself. The parts inside keep their own presses. */
186
+ onPress?: PressableProps['onPress'];
187
+ children?: ReactNode;
188
+ }
189
+
190
+ const PostRoot = forwardRef<View, PostProps>(
191
+ ({ className, variant = 'feed', size = 'default', onPress, children, ...props }, ref) => {
192
+ const { root } = postVariants({ variant, size });
193
+ const context = useMemo(() => ({ variant, size }), [variant, size]);
194
+
195
+ const body = (
196
+ <PostContext.Provider value={context}>{children}</PostContext.Provider>
197
+ );
198
+
199
+ if (!onPress) {
200
+ return (
201
+ <View ref={ref} className={root({ className })} {...props}>
202
+ {body}
203
+ </View>
204
+ );
205
+ }
206
+
207
+ return (
208
+ // `article` rather than `button`: the card opens, but the row of controls
209
+ // inside it is the reason anyone is here, and announcing the whole thing
210
+ // as one button buries them.
211
+ <AnimatedPressable
212
+ ref={ref}
213
+ accessibilityRole="link"
214
+ onPress={onPress}
215
+ className={root({ className })}
216
+ {...props}
217
+ >
218
+ {body}
219
+ </AnimatedPressable>
220
+ );
221
+ }
222
+ );
223
+ PostRoot.displayName = 'Post';
224
+
225
+ /* -------------------------------------------------------------------------- */
226
+ /* Header */
227
+ /* -------------------------------------------------------------------------- */
228
+
229
+ export interface PostHeaderProps extends ViewProps {
230
+ className?: string;
231
+ children?: ReactNode;
232
+ }
233
+
234
+ /**
235
+ * The author row. `Post.Action` is pulled to the trailing edge, so the overflow
236
+ * menu stays in the corner as a long display name wraps rather than riding down
237
+ * with it.
238
+ */
239
+ const PostHeader = forwardRef<View, PostHeaderProps>(
240
+ ({ className, children, ...props }, ref) => {
241
+ const { variant, size } = usePost();
242
+ const { header } = postVariants({ variant, size });
243
+
244
+ const content: ReactNode[] = [];
245
+ const actions: ReactNode[] = [];
246
+ for (const child of Array.isArray(children) ? children : [children]) {
247
+ if (isType(child, PostAction)) actions.push(child);
248
+ else content.push(child);
249
+ }
250
+
251
+ return (
252
+ <View ref={ref} className={header({ className })} {...props}>
253
+ {content}
254
+ {actions}
255
+ </View>
256
+ );
257
+ }
258
+ );
259
+ PostHeader.displayName = 'Post.Header';
260
+
261
+ function isType(child: ReactNode, type: unknown): boolean {
262
+ return (
263
+ typeof child === 'object' &&
264
+ child !== null &&
265
+ 'type' in child &&
266
+ (child as { type?: unknown }).type === type
267
+ );
268
+ }
269
+
270
+ export interface PostAuthorProps extends Omit<ViewProps, 'children'> {
271
+ className?: string;
272
+ /** Display name. */
273
+ name: string;
274
+ /** `@handle`, shown beside the name in `compact` and under it elsewhere. */
275
+ handle?: string;
276
+ avatar?: ImageSourcePropType;
277
+ /** Initials behind a missing or broken avatar. */
278
+ fallback?: string;
279
+ /** Draws the verification rosette after the name. */
280
+ verified?: boolean;
281
+ /** "Posted 3m ago" — whatever the caller wants to call the time. */
282
+ timestamp?: ReactNode;
283
+ children?: ReactNode;
284
+ }
285
+
286
+ /**
287
+ * Who posted it.
288
+ *
289
+ * The handle sits beside the name in `compact` and beneath it everywhere else,
290
+ * which is the difference between a timeline row and a card: a dense row cannot
291
+ * afford a second line, and a card looks starved without one.
292
+ */
293
+ const PostAuthor = forwardRef<View, PostAuthorProps>(
294
+ (
295
+ { className, name, handle, avatar, fallback, verified, timestamp, children, ...props },
296
+ ref
297
+ ) => {
298
+ const { variant, size } = usePost();
299
+ const { author, authorLine, name: nameSlot, handle: handleSlot, meta } = postVariants({
300
+ variant,
301
+ size,
302
+ });
303
+ const verifiedColor = useCSSVariable('--color-primary');
304
+ const onVerified = useCSSVariable('--color-primary-foreground');
305
+
306
+ const inline = variant === 'compact';
307
+ const overlaid = variant === 'media';
308
+
309
+ return (
310
+ // Top-aligned, not centred. The name is the first line of the block, and
311
+ // aligning to the top is what puts it — and therefore the menu opposite
312
+ // it — on the same rule in every variant, whether the block below the
313
+ // name is one line, two, or none.
314
+ <View ref={ref} className="min-w-0 flex-1 flex-row items-start gap-2.5" {...props}>
315
+ <Avatar
316
+ size={variant === 'compact' ? 'sm' : 'md'}
317
+ source={avatar}
318
+ fallback={fallback ?? initials(name)}
319
+ className={overlaid ? 'border-white/30' : undefined}
320
+ />
321
+ <View className={author({ className })}>
322
+ {/* `h-6` fixes the name's line box, so the block below it starts at
323
+ the same offset whatever the type scale rounds the line height
324
+ to — which is what the menu opposite is aligning against. */}
325
+ <View className={authorLine({ className: 'h-6' })}>
326
+ <Text
327
+ numberOfLines={1}
328
+ className={nameSlot({ className: cn('shrink', overlaid && 'text-white') })}
329
+ >
330
+ {name}
331
+ </Text>
332
+ {verified ? (
333
+ <BadgeCheckIcon
334
+ size={15}
335
+ color={typeof verifiedColor === 'string' ? verifiedColor : '#2563eb'}
336
+ checkColor={typeof onVerified === 'string' ? onVerified : '#ffffff'}
337
+ />
338
+ ) : null}
339
+ {inline && handle ? (
340
+ <Text
341
+ size="sm"
342
+ numberOfLines={1}
343
+ className={handleSlot({ className: 'shrink' })}
344
+ >
345
+ {handle}
346
+ </Text>
347
+ ) : null}
348
+ {inline && timestamp !== undefined ? (
349
+ <>
350
+ <Text size="sm" className={handleSlot()}>
351
+ ·
352
+ </Text>
353
+ {textChildren(timestamp, (text) => (
354
+ <Text size="sm" className={handleSlot()}>
355
+ {text}
356
+ </Text>
357
+ ))}
358
+ </>
359
+ ) : null}
360
+ </View>
361
+
362
+ {!inline && (handle || timestamp !== undefined) ? (
363
+ <Text
364
+ numberOfLines={1}
365
+ className={meta({ className: overlaid ? 'text-white/80' : undefined })}
366
+ >
367
+ {[handle, typeof timestamp === 'string' ? timestamp : undefined]
368
+ .filter(Boolean)
369
+ .join(' · ')}
370
+ </Text>
371
+ ) : null}
372
+ {!inline && timestamp !== undefined && typeof timestamp !== 'string'
373
+ ? timestamp
374
+ : null}
375
+ {children}
376
+ </View>
377
+ </View>
378
+ );
379
+ }
380
+ );
381
+ PostAuthor.displayName = 'Post.Author';
382
+
383
+ /** First letters of the first two words — enough to tell two people apart. */
384
+ function initials(name: string): string {
385
+ return name
386
+ .split(/\s+/)
387
+ .slice(0, 2)
388
+ .map((word) => word[0] ?? '')
389
+ .join('')
390
+ .toUpperCase();
391
+ }
392
+
393
+ export interface PostActionProps extends ViewProps {
394
+ className?: string;
395
+ children?: ReactNode;
396
+ }
397
+
398
+ /** The header's trailing slot — an overflow menu, a follow button, a badge. */
399
+ const PostAction = forwardRef<View, PostActionProps>(
400
+ ({ className, children, ...props }, ref) => {
401
+ const { variant, size } = usePost();
402
+ const { action } = postVariants({ variant, size });
403
+ return (
404
+ <View ref={ref} className={action({ className })} {...props}>
405
+ {children}
406
+ </View>
407
+ );
408
+ }
409
+ );
410
+ PostAction.displayName = 'Post.Action';
411
+
412
+ export interface PostCommunityProps extends Omit<ViewProps, 'children'> {
413
+ className?: string;
414
+ /** The group's name — "r/reactnative", "#design". */
415
+ name: string;
416
+ avatar?: ImageSourcePropType;
417
+ /** How long ago, and anything else that belongs on the line. */
418
+ meta?: ReactNode;
419
+ children?: ReactNode;
420
+ }
421
+
422
+ /**
423
+ * The group a post was made in, above its headline.
424
+ *
425
+ * Smaller and quieter than an author row, because in a ranked community the
426
+ * post's subject is the headline — the group is where it came from, not who
427
+ * said it.
428
+ */
429
+ const PostCommunity = forwardRef<View, PostCommunityProps>(
430
+ ({ className, name, avatar, meta, children, ...props }, ref) => {
431
+ const { variant, size } = usePost();
432
+ const {
433
+ community,
434
+ communityName,
435
+ communityMeta,
436
+ } = postVariants({ variant, size });
437
+
438
+ return (
439
+ <View ref={ref} className={community({ className })} {...props}>
440
+ <Avatar
441
+ size="sm"
442
+ source={avatar}
443
+ fallback={name.replace(/^[^a-z0-9]*/i, '').slice(0, 1).toUpperCase()}
444
+ className="h-6 w-6"
445
+ />
446
+ <Text numberOfLines={1} className={communityName({ className: 'shrink' })}>
447
+ {name}
448
+ </Text>
449
+ {meta !== undefined ? (
450
+ <>
451
+ <Text className={communityMeta()}>·</Text>
452
+ {textChildren(meta, (text) => (
453
+ <Text className={communityMeta()}>{text}</Text>
454
+ ))}
455
+ </>
456
+ ) : null}
457
+ {children}
458
+ </View>
459
+ );
460
+ }
461
+ );
462
+ PostCommunity.displayName = 'Post.Community';
463
+
464
+ /* -------------------------------------------------------------------------- */
465
+ /* Body */
466
+ /* -------------------------------------------------------------------------- */
467
+
468
+ export interface PostTitleProps extends ViewProps {
469
+ className?: string;
470
+ children?: ReactNode;
471
+ }
472
+
473
+ /** The headline of a post whose subject is a headline rather than a person. */
474
+ const PostTitle = forwardRef<View, PostTitleProps>(
475
+ ({ className, children, ...props }, ref) => {
476
+ const { variant, size } = usePost();
477
+ const { title } = postVariants({ variant, size });
478
+ return (
479
+ <View ref={ref} className={cn('px-4 pb-2', className)} {...props}>
480
+ {textChildren(children, (text) => <Text className={title()}>{text}</Text>)}
481
+ </View>
482
+ );
483
+ }
484
+ );
485
+ PostTitle.displayName = 'Post.Title';
486
+
487
+ export interface PostBodyProps extends Omit<ViewProps, 'children'> {
488
+ className?: string;
489
+ /** How many lines before it is cut off. Unlimited by default. */
490
+ numberOfLines?: number;
491
+ /** Called with the tag, without its `#`. Makes hashtags pressable. */
492
+ onTagPress?: (tag: string) => void;
493
+ /** Called with the handle, without its `@`. */
494
+ onMentionPress?: (handle: string) => void;
495
+ children?: ReactNode;
496
+ }
497
+
498
+ /**
499
+ * What was written.
500
+ *
501
+ * Hashtags and mentions inside a string child are picked out and coloured. They
502
+ * are found in the text rather than declared, because a post body arrives as
503
+ * one string from wherever it was typed — asking the caller to pre-split it
504
+ * would mean every caller writing the same tokeniser.
505
+ *
506
+ * A non-string child is left entirely alone: it has already been laid out by
507
+ * someone who knew more than we do.
508
+ */
509
+ const PostBody = forwardRef<View, PostBodyProps>(
510
+ (
511
+ { className, numberOfLines, onTagPress, onMentionPress, children, ...props },
512
+ ref
513
+ ) => {
514
+ const { variant, size } = usePost();
515
+ const { body } = postVariants({ variant, size });
516
+
517
+ if (typeof children !== 'string') {
518
+ return (
519
+ <View ref={ref} className={cn(body(), className)} {...props}>
520
+ {children}
521
+ </View>
522
+ );
523
+ }
524
+
525
+ return (
526
+ <View ref={ref} className={cn(body({ className }))} {...props}>
527
+ <Text numberOfLines={numberOfLines} className={body({ className: 'p-0' })}>
528
+ {tokenize(children).map((token, index) =>
529
+ token.kind === 'text' ? (
530
+ token.value
531
+ ) : (
532
+ <Text
533
+ key={index}
534
+ className="text-primary"
535
+ onPress={
536
+ token.kind === 'tag'
537
+ ? onTagPress && (() => onTagPress(token.value.slice(1)))
538
+ : onMentionPress && (() => onMentionPress(token.value.slice(1)))
539
+ }
540
+ >
541
+ {token.value}
542
+ </Text>
543
+ )
544
+ )}
545
+ </Text>
546
+ </View>
547
+ );
548
+ }
549
+ );
550
+ PostBody.displayName = 'Post.Body';
551
+
552
+ interface Token {
553
+ kind: 'text' | 'tag' | 'mention';
554
+ value: string;
555
+ }
556
+
557
+ /**
558
+ * Splits a body into plain runs, hashtags and mentions.
559
+ *
560
+ * The tag pattern stops at whitespace and at punctuation that ends a sentence,
561
+ * so "#DebtSnowball." highlights the tag and leaves the full stop black — the
562
+ * naive `\S+` swallows it and the sentence loses its ending.
563
+ */
564
+ function tokenize(text: string): Token[] {
565
+ const pattern = /[#@][\w-]+/g;
566
+ const tokens: Token[] = [];
567
+ let last = 0;
568
+
569
+ for (const match of text.matchAll(pattern)) {
570
+ const start = match.index ?? 0;
571
+ if (start > last) tokens.push({ kind: 'text', value: text.slice(last, start) });
572
+ tokens.push({
573
+ kind: match[0].startsWith('#') ? 'tag' : 'mention',
574
+ value: match[0],
575
+ });
576
+ last = start + match[0].length;
577
+ }
578
+ if (last < text.length) tokens.push({ kind: 'text', value: text.slice(last) });
579
+ return tokens;
580
+ }
581
+
582
+ export interface PostMediaProps extends Omit<ViewProps, 'children'> {
583
+ className?: string;
584
+ source: ImageSourcePropType;
585
+ /** Width over height. `16 / 10` by default — wide enough not to eat the feed. */
586
+ aspectRatio?: number;
587
+ /**
588
+ * Darkens an edge of the image so type laid over it stays legible.
589
+ *
590
+ * A gradient rather than a panel: a flat rectangle over the top of a
591
+ * photograph has an edge of its own, and that edge reads as a bar covering
592
+ * the picture rather than as shading. `media` posts default to `top`, where
593
+ * the author sits; everything else to `none`.
594
+ */
595
+ scrim?: 'none' | 'top' | 'bottom' | 'both';
596
+ /** Laid over the image: an expand affordance, a duration, a gallery count. */
597
+ overlay?: ReactNode;
598
+ /** Described for a screen reader. An image with nothing to say is decorative. */
599
+ alt?: string;
600
+ onPress?: PressableProps['onPress'];
601
+ children?: ReactNode;
602
+ }
603
+
604
+ /**
605
+ * Black, fading to nothing.
606
+ *
607
+ * Three stops rather than two: a straight linear ramp from 55% to nothing has a
608
+ * visible shoulder where it meets the picture, and the eye reads that shoulder
609
+ * as an edge — which is the whole thing a scrim exists to avoid.
610
+ */
611
+ const SCRIM_COLOURS: readonly [string, string, string] = [
612
+ 'rgba(0,0,0,0.55)',
613
+ 'rgba(0,0,0,0.28)',
614
+ 'rgba(0,0,0,0)',
615
+ ];
616
+
617
+ /**
618
+ * The picture.
619
+ *
620
+ * A fixed aspect ratio rather than the image's own, because a feed of cards
621
+ * whose heights are decided by whatever was uploaded scrolls like a broken
622
+ * staircase. The image covers the box and is cropped by it.
623
+ */
624
+ const PostMedia = forwardRef<View, PostMediaProps>(
625
+ (
626
+ {
627
+ className,
628
+ source,
629
+ aspectRatio = 16 / 10,
630
+ scrim,
631
+ overlay,
632
+ alt,
633
+ onPress,
634
+ children,
635
+ ...props
636
+ },
637
+ ref
638
+ ) => {
639
+ const { variant, size } = usePost();
640
+ const { media } = postVariants({ variant, size });
641
+ // The media variant lays the author over the image, so it needs shading by
642
+ // default — the caller should not have to remember what makes their own
643
+ // layout readable.
644
+ const shade = scrim ?? (variant === 'media' ? 'top' : 'none');
645
+
646
+ const picture = (
647
+ <>
648
+ <Image
649
+ source={source}
650
+ resizeMode="cover"
651
+ accessible={!!alt}
652
+ accessibilityLabel={alt}
653
+ accessibilityRole={alt ? 'image' : undefined}
654
+ className="h-full w-full"
655
+ />
656
+ {shade === 'top' || shade === 'both' ? (
657
+ <LinearGradient
658
+ pointerEvents="none"
659
+ colors={SCRIM_COLOURS}
660
+ // A fixed height, not a fraction. The scrim is there to carry one
661
+ // row of type, and a fraction of a 4:5 portrait shades half the
662
+ // photograph to do a job that needs the top 112 points of it.
663
+ className="absolute inset-x-0 top-0 h-28"
664
+ />
665
+ ) : null}
666
+ {shade === 'bottom' || shade === 'both' ? (
667
+ <LinearGradient
668
+ pointerEvents="none"
669
+ colors={[SCRIM_COLOURS[2], SCRIM_COLOURS[1], SCRIM_COLOURS[0]]}
670
+ className="absolute inset-x-0 bottom-0 h-32"
671
+ />
672
+ ) : null}
673
+ {overlay ? (
674
+ <View pointerEvents="box-none" className="absolute inset-0">
675
+ {overlay}
676
+ </View>
677
+ ) : null}
678
+ {children}
679
+ </>
680
+ );
681
+
682
+ if (!onPress) {
683
+ return (
684
+ <View ref={ref} className={media({ className })} style={{ aspectRatio }} {...props}>
685
+ {picture}
686
+ </View>
687
+ );
688
+ }
689
+
690
+ return (
691
+ <Pressable
692
+ ref={ref}
693
+ accessibilityRole="imagebutton"
694
+ accessibilityLabel={alt}
695
+ onPress={onPress}
696
+ className={media({ className })}
697
+ style={{ aspectRatio }}
698
+ {...props}
699
+ >
700
+ {picture}
701
+ </Pressable>
702
+ );
703
+ }
704
+ );
705
+ PostMedia.displayName = 'Post.Media';
706
+
707
+ /* -------------------------------------------------------------------------- */
708
+ /* Footer */
709
+ /* -------------------------------------------------------------------------- */
710
+
711
+ export interface PostFooterProps extends ViewProps {
712
+ className?: string;
713
+ children?: ReactNode;
714
+ }
715
+
716
+ /** The row of counts and controls under the post. */
717
+ const PostFooter = forwardRef<View, PostFooterProps>(
718
+ ({ className, children, ...props }, ref) => {
719
+ const { variant, size } = usePost();
720
+ const { footer } = postVariants({ variant, size });
721
+ return (
722
+ <View ref={ref} className={footer({ className })} {...props}>
723
+ {children}
724
+ </View>
725
+ );
726
+ }
727
+ );
728
+ PostFooter.displayName = 'Post.Footer';
729
+
730
+ /** What lighting up means. A like is red, a save is the accent, a repost green. */
731
+ const statTone: Record<'default' | 'like' | 'save' | 'repost', string> = {
732
+ default: '--color-primary',
733
+ like: '--color-destructive',
734
+ save: '--color-primary',
735
+ repost: '--color-success',
736
+ };
737
+
738
+ export interface PostStatProps extends Omit<PressableProps, 'children' | 'style'> {
739
+ className?: string;
740
+ /** The icon component itself, not an element — it is re-rendered on toggle. */
741
+ icon?: ComponentType<IconProps & { filled?: boolean }>;
742
+ /** The number, or a word where a number would be meaningless ("Save"). */
743
+ value?: ReactNode;
744
+ /** Lit, and filled. */
745
+ active?: boolean;
746
+ /** Which colour "lit" is. */
747
+ tone?: 'default' | 'like' | 'save' | 'repost';
748
+ /** Pushes this stat and everything after it to the trailing edge. */
749
+ align?: 'start' | 'end';
750
+ children?: ReactNode;
751
+ }
752
+
753
+ /**
754
+ * One count in the footer, and the control that changes it.
755
+ *
756
+ * Pressing it pops the icon past its own size and settles back, fills it, and
757
+ * animates the value: the old number leaves upwards and the new one arrives
758
+ * from below when the count goes up, and the other way when it comes down.
759
+ * Repainting the digits in place is indistinguishable from a re-render, which
760
+ * is the doubt this exists to remove.
761
+ */
762
+ const PostStat = forwardRef<View, PostStatProps>(
763
+ (
764
+ {
765
+ className,
766
+ icon: Icon,
767
+ value,
768
+ active = false,
769
+ tone = 'default',
770
+ align = 'start',
771
+ onPress,
772
+ children,
773
+ ...props
774
+ },
775
+ ref
776
+ ) => {
777
+ const { variant, size } = usePost();
778
+ const { stat, statLabel } = postVariants({ variant, size });
779
+ const litColor = useCSSVariable(statTone[tone]);
780
+ const restColor = useCSSVariable('--color-muted-foreground');
781
+ const reducedMotion = useReducedMotion();
782
+
783
+ const pop = useSharedValue(1);
784
+ const first = useRef(true);
785
+
786
+ useEffect(() => {
787
+ if (first.current) {
788
+ first.current = false;
789
+ return;
790
+ }
791
+ if (reducedMotion) return;
792
+ // Past 1 and back. A toggle that only grows reads as a state that got
793
+ // bigger; one that overshoots reads as a press.
794
+ pop.value = withSequence(
795
+ withTiming(active ? 1.25 : 0.85, { duration: 90 }),
796
+ withSpring(1, POP)
797
+ );
798
+ }, [active, reducedMotion, pop]);
799
+
800
+ const iconStyle = useAnimatedStyle(() => ({ transform: [{ scale: pop.value }] }));
801
+
802
+ const colour = active
803
+ ? typeof litColor === 'string'
804
+ ? litColor
805
+ : '#e11d48'
806
+ : typeof restColor === 'string'
807
+ ? restColor
808
+ : '#737373';
809
+
810
+ const inner = (
811
+ <>
812
+ {Icon ? (
813
+ <Animated.View style={iconStyle}>
814
+ <Icon size={variant === 'compact' ? 15 : 17} color={colour} filled={active} />
815
+ </Animated.View>
816
+ ) : null}
817
+ {value !== undefined ? (
818
+ <PostCount
819
+ value={value}
820
+ className={statLabel({ className: active ? '' : undefined })}
821
+ color={active ? colour : undefined}
822
+ />
823
+ ) : null}
824
+ {children}
825
+ </>
826
+ );
827
+
828
+ const classes = stat({ className: cn(align === 'end' && 'ms-auto', className) });
829
+
830
+ if (!onPress) {
831
+ return (
832
+ <View ref={ref} className={classes}>
833
+ {inner}
834
+ </View>
835
+ );
836
+ }
837
+
838
+ return (
839
+ <Pressable
840
+ ref={ref}
841
+ accessibilityRole="button"
842
+ accessibilityState={{ selected: active }}
843
+ // The row is dense, so the targets overlap their own boxes rather than
844
+ // being spaced far enough apart to be hit — spacing them would be a
845
+ // footer twice as wide as the numbers in it.
846
+ hitSlop={10}
847
+ onPress={(event) => {
848
+ selectionTick();
849
+ onPress(event);
850
+ }}
851
+ className={classes}
852
+ {...props}
853
+ >
854
+ {inner}
855
+ </Pressable>
856
+ );
857
+ }
858
+ );
859
+ PostStat.displayName = 'Post.Stat';
860
+
861
+ /**
862
+ * A number that moves when it changes.
863
+ *
864
+ * The new value arrives from the direction the count travelled — rising into
865
+ * place when it went up, dropping in when it came down — while the old one
866
+ * fades. `215 → 216` then reads as a count going up rather than as text being
867
+ * replaced, which is the doubt worth removing: repainting digits in place is
868
+ * indistinguishable from a re-render.
869
+ *
870
+ * Only the entering half carries the direction. The element leaving was
871
+ * rendered before anyone knew which way the number would go, so asking it to
872
+ * animate knowingly would mean knowing the future.
873
+ *
874
+ * A value that is not a number — "Save" — cross-fades, since there is no
875
+ * direction for a word to go.
876
+ */
877
+ function PostCount({
878
+ value,
879
+ className,
880
+ color,
881
+ }: {
882
+ value: ReactNode;
883
+ className?: string;
884
+ color?: string;
885
+ }) {
886
+ const reducedMotion = useReducedMotion();
887
+ // Read during the render the value changes on, not after it: an effect would
888
+ // settle the direction one frame after the animation had already started.
889
+ const before = numeric(usePrevious(value));
890
+ const after = numeric(value);
891
+ const direction =
892
+ before === null || after === null || before === after ? 0 : after > before ? 1 : -1;
893
+
894
+ const label = (
895
+ <Text className={className} style={color ? { color } : undefined}>
896
+ {value}
897
+ </Text>
898
+ );
899
+
900
+ if (reducedMotion) return label;
901
+
902
+ return (
903
+ <Animated.View
904
+ // Keyed on the value, so React tears the old one down and builds the new
905
+ // one — which is what gives the two of them an entering and an exiting
906
+ // animation to run at all.
907
+ key={String(value)}
908
+ entering={
909
+ direction === 1
910
+ ? FadeInUp.duration(COUNT_DURATION)
911
+ : direction === -1
912
+ ? FadeInDown.duration(COUNT_DURATION)
913
+ : FadeIn.duration(COUNT_DURATION)
914
+ }
915
+ exiting={FadeOut.duration(COUNT_DURATION)}
916
+ >
917
+ {label}
918
+ </Animated.View>
919
+ );
920
+ }
921
+
922
+ /** The value as a number, where it is one. `"5,874"` counts; `"Save"` does not. */
923
+ function numeric(value: ReactNode): number | null {
924
+ if (typeof value === 'number') return value;
925
+ if (typeof value !== 'string') return null;
926
+ const parsed = Number(value.replace(/[,\s]/g, ''));
927
+ return Number.isFinite(parsed) ? parsed : null;
928
+ }
929
+
930
+ /* -------------------------------------------------------------------------- */
931
+ /* Votes */
932
+ /* -------------------------------------------------------------------------- */
933
+
934
+ export type PostVote = 'up' | 'down' | null;
935
+
936
+ export interface PostVotesProps extends Omit<ViewProps, 'children'> {
937
+ className?: string;
938
+ /** The score as it stands, with the reader's own vote already in it. */
939
+ score: number | string;
940
+ /** Which way this reader voted, if either. */
941
+ vote?: PostVote;
942
+ /**
943
+ * Called with the new vote. Pressing the arrow already cast clears it, so
944
+ * `null` arrives as often as the other two — a vote you cannot take back is
945
+ * a vote people hesitate over.
946
+ */
947
+ onVote?: (vote: PostVote) => void;
948
+ /** `vertical` stacks the arrows beside a thumbnail, the way a ranked list reads. */
949
+ orientation?: 'horizontal' | 'vertical';
950
+ disabled?: boolean;
951
+ }
952
+
953
+ /**
954
+ * The score, and the two arrows that move it.
955
+ *
956
+ * Both arrows fill and take a colour when cast, and the whole pill tints with
957
+ * them — the arrow alone is 16pt of a card, which is not enough to notice from
958
+ * arm's length. The score rolls in the direction of the change.
959
+ *
960
+ * Pressing the arrow already cast clears the vote. This is not a nicety: a
961
+ * score is a thing people change their minds about, and a vote that cannot be
962
+ * withdrawn is one they think twice before casting.
963
+ */
964
+ const PostVotes = forwardRef<View, PostVotesProps>(
965
+ (
966
+ {
967
+ className,
968
+ score,
969
+ vote = null,
970
+ onVote,
971
+ orientation = 'horizontal',
972
+ disabled = false,
973
+ ...props
974
+ },
975
+ ref
976
+ ) => {
977
+ const { variant, size } = usePost();
978
+ const { votes, voteButton, voteScore } = postVariants({ variant, size });
979
+ const upColor = useCSSVariable('--color-success');
980
+ const downColor = useCSSVariable('--color-destructive');
981
+ const restColor = useCSSVariable('--color-muted-foreground');
982
+
983
+ const up = typeof upColor === 'string' ? upColor : '#16a34a';
984
+ const down = typeof downColor === 'string' ? downColor : '#dc2626';
985
+ const rest = typeof restColor === 'string' ? restColor : '#737373';
986
+
987
+ const cast = (next: 'up' | 'down') => {
988
+ selectionTick();
989
+ onVote?.(vote === next ? null : next);
990
+ };
991
+
992
+ const scoreColour = vote === 'up' ? up : vote === 'down' ? down : undefined;
993
+
994
+ return (
995
+ <View
996
+ ref={ref}
997
+ accessibilityRole="adjustable"
998
+ accessibilityLabel="Score"
999
+ accessibilityValue={{ text: String(score) }}
1000
+ className={votes({
1001
+ className: cn(
1002
+ orientation === 'vertical' && 'flex-col gap-0',
1003
+ vote === 'up' && 'bg-success/12',
1004
+ vote === 'down' && 'bg-destructive/12',
1005
+ className
1006
+ ),
1007
+ })}
1008
+ {...props}
1009
+ >
1010
+ <VoteArrow
1011
+ direction="up"
1012
+ active={vote === 'up'}
1013
+ color={vote === 'up' ? up : rest}
1014
+ disabled={disabled}
1015
+ className={voteButton()}
1016
+ onPress={() => cast('up')}
1017
+ />
1018
+ <PostCount
1019
+ value={score}
1020
+ className={voteScore({ className: 'min-w-6 text-center' })}
1021
+ color={scoreColour}
1022
+ />
1023
+ <VoteArrow
1024
+ direction="down"
1025
+ active={vote === 'down'}
1026
+ color={vote === 'down' ? down : rest}
1027
+ disabled={disabled}
1028
+ className={voteButton()}
1029
+ onPress={() => cast('down')}
1030
+ />
1031
+ </View>
1032
+ );
1033
+ }
1034
+ );
1035
+ PostVotes.displayName = 'Post.Votes';
1036
+
1037
+ /**
1038
+ * One arrow.
1039
+ *
1040
+ * It travels along its own axis as it is cast — up for the up arrow, down for
1041
+ * the down one — and springs back. The direction of the throw is the whole
1042
+ * point: an arrow that merely scales says *something happened*, one that moves
1043
+ * the way it points says *which way*.
1044
+ */
1045
+ function VoteArrow({
1046
+ direction,
1047
+ active,
1048
+ color,
1049
+ disabled,
1050
+ className,
1051
+ onPress,
1052
+ }: {
1053
+ direction: 'up' | 'down';
1054
+ active: boolean;
1055
+ color: string;
1056
+ disabled: boolean;
1057
+ className: string;
1058
+ onPress: () => void;
1059
+ }) {
1060
+ const reducedMotion = useReducedMotion();
1061
+ const shift = useSharedValue(0);
1062
+ const scale = useSharedValue(1);
1063
+ const first = useRef(true);
1064
+
1065
+ useEffect(() => {
1066
+ if (first.current) {
1067
+ first.current = false;
1068
+ return;
1069
+ }
1070
+ if (reducedMotion) return;
1071
+ const travel = direction === 'up' ? -5 : 5;
1072
+ shift.value = withSequence(
1073
+ withTiming(active ? travel : -travel * 0.4, { duration: 100 }),
1074
+ withSpring(0, POP)
1075
+ );
1076
+ scale.value = withSequence(
1077
+ withTiming(active ? 1.2 : 0.9, { duration: 100 }),
1078
+ withSpring(1, POP)
1079
+ );
1080
+ }, [active, direction, reducedMotion, shift, scale]);
1081
+
1082
+ const style = useAnimatedStyle(() => ({
1083
+ transform: [{ translateY: shift.value }, { scale: scale.value }],
1084
+ }));
1085
+
1086
+ const Arrow = direction === 'up' ? ArrowUpIcon : ArrowDownIcon;
1087
+
1088
+ return (
1089
+ <Pressable
1090
+ accessibilityRole="button"
1091
+ accessibilityLabel={direction === 'up' ? 'Upvote' : 'Downvote'}
1092
+ accessibilityState={{ selected: active, disabled }}
1093
+ disabled={disabled}
1094
+ hitSlop={6}
1095
+ onPress={onPress}
1096
+ className={cn(className, disabled && 'opacity-[0.64]')}
1097
+ >
1098
+ <Animated.View style={style}>
1099
+ <Arrow size={17} color={color} filled={active} />
1100
+ </Animated.View>
1101
+ </Pressable>
1102
+ );
1103
+ }
1104
+
1105
+ export const Post = Object.assign(PostRoot, {
1106
+ Header: PostHeader,
1107
+ Author: PostAuthor,
1108
+ Action: PostAction,
1109
+ Community: PostCommunity,
1110
+ Title: PostTitle,
1111
+ Body: PostBody,
1112
+ Media: PostMedia,
1113
+ Footer: PostFooter,
1114
+ Stat: PostStat,
1115
+ Votes: PostVotes,
1116
+ });