panelui-native 0.30.0 → 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.
- package/README.md +4 -2
- package/lib/module/components/calendar/index.js +107 -24
- package/lib/module/components/calendar/index.js.map +1 -1
- package/lib/module/components/heatmap-chart/index.js +9 -8
- package/lib/module/components/heatmap-chart/index.js.map +1 -1
- package/lib/module/components/map/index.js +50 -4
- package/lib/module/components/map/index.js.map +1 -1
- package/lib/module/components/plan/index.js +307 -21
- package/lib/module/components/plan/index.js.map +1 -1
- package/lib/module/components/post/index.js +992 -0
- package/lib/module/components/post/index.js.map +1 -0
- package/lib/module/components/response/index.js +296 -0
- package/lib/module/components/response/index.js.map +1 -0
- package/lib/module/components/response/markdown.js +463 -0
- package/lib/module/components/response/markdown.js.map +1 -0
- package/lib/module/components/select/index.js +97 -15
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/table/index.js +632 -0
- package/lib/module/components/table/index.js.map +1 -0
- package/lib/module/icons/index.js +200 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +4 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/date.js +129 -22
- package/lib/module/utils/date.js.map +1 -1
- package/lib/typescript/src/components/calendar/index.d.ts +12 -3
- package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
- package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/map/index.d.ts +8 -1
- package/lib/typescript/src/components/map/index.d.ts.map +1 -1
- package/lib/typescript/src/components/plan/index.d.ts +121 -10
- package/lib/typescript/src/components/plan/index.d.ts.map +1 -1
- package/lib/typescript/src/components/post/index.d.ts +358 -0
- package/lib/typescript/src/components/post/index.d.ts.map +1 -0
- package/lib/typescript/src/components/response/index.d.ts +91 -0
- package/lib/typescript/src/components/response/index.d.ts.map +1 -0
- package/lib/typescript/src/components/response/markdown.d.ts +88 -0
- package/lib/typescript/src/components/response/markdown.d.ts.map +1 -0
- package/lib/typescript/src/components/select/index.d.ts +27 -2
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/table/index.d.ts +274 -0
- package/lib/typescript/src/components/table/index.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +40 -1
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +5 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/date.d.ts +49 -5
- package/lib/typescript/src/utils/date.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/calendar/index.tsx +136 -23
- package/src/components/heatmap-chart/index.tsx +9 -9
- package/src/components/map/index.tsx +53 -4
- package/src/components/plan/index.tsx +344 -21
- package/src/components/post/index.tsx +1116 -0
- package/src/components/response/index.tsx +390 -0
- package/src/components/response/markdown.ts +489 -0
- package/src/components/select/index.tsx +132 -12
- package/src/components/table/index.tsx +747 -0
- package/src/icons/index.tsx +162 -1
- package/src/index.ts +60 -0
- package/src/utils/date.ts +146 -22
|
@@ -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
|
+
});
|