@lotics/ui 47.8.0 → 47.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/catalog.md CHANGED
@@ -526,7 +526,9 @@ patterns doc indexed in [AGENTS.md](../AGENTS.md)).
526
526
  upcoming, horizontal OR vertical), `RemainderMeter` + `AllocationRow` (allocation),
527
527
  `Timeline` (a heterogeneous event LOG — icons + expandable details, not progress),
528
528
  `CalendarView` (the `calendar` module — days are the axis), `GanttView` (RESOURCES are the
529
- axis: a channel, an owner, a vehicle), `comments_thread`.
529
+ axis: a channel, an owner, a vehicle), `comments_thread`, `SocialPostPreview` (a post as ONE
530
+ network's feed shows it, with the count against that network's limit and every rule its API
531
+ would refuse — the publish gate reads the same `socialPostIssues` the preview draws).
530
532
 
531
533
  ### AI surfaces
532
534
 
@@ -606,7 +608,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
606
608
  `SubsectionHeading` instead. Pick between this and `Eyebrow` by what is LABELLED — a block or a
607
609
  value; composition.md § Eyebrow vs group lead has the table.
608
610
  - **`brand_mark`** — `BrandMark`: an OUTSIDE brand's mark, naming the channel a touchpoint
609
- happened on — `facebook`, `zalo`, `linkedin`, `x`, `google-meet`. Solid single-colour silhouettes
611
+ happened on — `facebook`, `instagram`, `threads`, `zalo`, `linkedin`, `x`, `google-meet`. Solid single-colour silhouettes
610
612
  in each brand's own hex, drawn from CC0 geometry. `size` (default 16) and `muted` (draw in the
611
613
  text colour). The name list is an ALLOWLIST — `BRAND_NAMES` is the array and `BrandName` derives
612
614
  from it, so a channel with no mark will not typecheck and the call site falls back to a generic
@@ -800,6 +802,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
800
802
  the product can name); `joined` comes in as an ISO DATE and the card formats it to month + year;
801
803
  `groups` distinguishes absent (no row) from `[]` ("None"). `inactive` draws the departed state as
802
804
  a `Badge` — a lifecycle state, not another muted line.
805
+ - **`social_post_preview`** — `SocialPostPreview`: a draft post as one network's feed would show
806
+ it — author line with the network's mark, text folded where THAT feed folds it (or the tail
807
+ past the limit in the danger ink, unfolded, so the characters to cut are the ones on screen),
808
+ media as a carousel strip (Instagram, Threads) or a tiled grid with overflow (Facebook, X,
809
+ LinkedIn) — a lone video plays inline, as every feed plays it — a link as the card or the
810
+ inert text it becomes, and a footer with `count / limit`
811
+ plus each refusal in words. `SOCIAL_NETWORK_RULES` is the one table both the drawing and
812
+ `socialPostIssues(network, draft)` read — both in `social_post_rules`, RN-free, so a gate reads
813
+ them without rendering — so a button gated on the issues refuses exactly what
814
+ the preview shows; `socialPostIssueText` renders one in the pack's words. Never writes, never
815
+ publishes. Counts in code points, which is a guide on X (it weights URLs and CJK), exact
816
+ elsewhere.
803
817
  - **`member_peek`** — `MemberPeek`: `MemberChip` + `Peek` + the card above, with the trigger's
804
818
  announced name taken from the locale. Off picker options and off rows that already press —
805
819
  see the person-display section for why.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "47.8.0",
3
+ "version": "47.9.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./accordion": "./src/accordion.tsx",
@@ -250,6 +250,8 @@
250
250
  "./share_or_download": "./src/share_or_download.ts",
251
251
  "./shortcut_badge": "./src/shortcut_badge.tsx",
252
252
  "./size_boundary": "./src/size_boundary.tsx",
253
+ "./social_post_preview": "./src/social_post_preview.tsx",
254
+ "./social_post_rules": "./src/social_post_rules.ts",
253
255
  "./skeleton": "./src/skeleton.tsx",
254
256
  "./skip_link": "./src/skip_link.tsx",
255
257
  "./slider": "./src/slider.tsx",
@@ -18,7 +18,7 @@ import { Svg, Path } from "react-native-svg";
18
18
  * plausible and slightly wrong, which is the one defect a reader spots instantly
19
19
  * and a test suite never does.
20
20
  */
21
- export const BRAND_NAMES = ["facebook", "meta", "tiktok", "google-ads", "zalo", "linkedin", "x", "google-meet"] as const;
21
+ export const BRAND_NAMES = ["facebook", "instagram", "threads", "meta", "tiktok", "google-ads", "zalo", "linkedin", "x", "google-meet"] as const;
22
22
 
23
23
  export type BrandName = (typeof BRAND_NAMES)[number];
24
24
 
@@ -73,6 +73,16 @@ const MARKS: Record<BrandName, Mark> = {
73
73
  color: "#0866FF",
74
74
  path: "M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z",
75
75
  },
76
+ /** The two Meta surfaces a post goes to under a PERSON's name rather than a
77
+ * Page's. Threads' mark is black, like X's — the same `muted` escape applies. */
78
+ instagram: {
79
+ color: "#FF0069",
80
+ path: "M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077",
81
+ },
82
+ threads: {
83
+ color: "#000000",
84
+ path: "M18.263 11.097c-.03-3.486-1.92-5.586-5.111-5.586-2.13 0-3.922.963-4.863 2.499l2.062 1.438c.535-.843 1.272-1.543 2.628-1.543 1.528 0 2.318.85 2.544 2.431a15 15 0 0 0-2.236-.173c-4.125 0-6.068 1.867-6.068 4.336s1.943 3.99 4.804 3.99c3.139 0 5.013-2.115 5.781-4.735.798.361 1.348 1.204 1.348 2.47 0 3.387-3.907 5.232-7.22 5.232-4.885 0-8.077-3.207-8.077-8.424 0-6.392 4.223-10.487 9.9-10.487 3.808 0 5.69 1.671 6.97 3.914l2.108-1.475C21.44 2.078 18.331 0 13.663 0 6.227 0 1.168 5.277 1.168 12.934c0 7 4.953 11.066 10.856 11.066 4.878 0 9.809-2.846 9.809-7.716 0-2.545-1.46-4.231-3.569-5.187m-6.33 4.855c-1.077 0-2.026-.512-2.026-1.453 0-1.483 1.822-1.934 3.606-1.934.678 0 1.34.045 1.927.173-.422 1.927-1.671 3.215-3.508 3.214Z",
85
+ },
76
86
  zalo: {
77
87
  color: "#0068FF",
78
88
  path: "M12.49 10.2722v-.4496h1.3467v6.3218h-.7704a.576.576 0 01-.5763-.5729l-.0006.0005a3.273 3.273 0 01-1.9372.6321c-1.8138 0-3.2844-1.4697-3.2844-3.2823 0-1.8125 1.4706-3.2822 3.2844-3.2822a3.273 3.273 0 011.9372.6321l.0006.0005zM6.9188 7.7896v.205c0 .3823-.051.6944-.2995 1.0605l-.03.0343c-.0542.0615-.1815.206-.2421.2843L2.024 14.8h4.8948v.7682a.5764.5764 0 01-.5767.5761H0v-.3622c0-.4436.1102-.6414.2495-.8476L4.8582 9.23H.1922V7.7896h6.7266zm8.5513 8.3548a.4805.4805 0 01-.4803-.4798v-7.875h1.4416v8.3548H15.47zM20.6934 9.6C22.52 9.6 24 11.0807 24 12.9044c0 1.8252-1.4801 3.306-3.3066 3.306-1.8264 0-3.3066-1.4808-3.3066-3.306 0-1.8237 1.4802-3.3044 3.3066-3.3044zm-10.1412 5.253c1.0675 0 1.9324-.8645 1.9324-1.9312 0-1.065-.865-1.9295-1.9324-1.9295s-1.9324.8644-1.9324 1.9295c0 1.0667.865 1.9312 1.9324 1.9312zm10.1412-.0033c1.0737 0 1.945-.8707 1.945-1.9453 0-1.073-.8713-1.9436-1.945-1.9436-1.0753 0-1.945.8706-1.945 1.9436 0 1.0746.8697 1.9453 1.945 1.9453z",
package/src/locale.tsx CHANGED
@@ -3,6 +3,7 @@ import { type DatePickerLabels } from "./date_picker";
3
3
  import { type CalendarLabels } from "./date_calendar";
4
4
  import { type CalendarViewLabels } from "./calendar/types";
5
5
  import { type GanttLabels } from "./gantt/types";
6
+ import { type SocialPostPreviewLabels } from "./social_post_rules";
6
7
  import { type PaginationLabels } from "./pagination";
7
8
  import { type SortHeaderLabels } from "./sort_header";
8
9
  import { type ConfidenceLabels } from "./confidence";
@@ -65,6 +66,9 @@ export interface LoticsLocale {
65
66
  /** `GanttView` — the zoom switch, the frozen column header, dependency
66
67
  * announcements and the empty state. */
67
68
  gantt: GanttLabels;
69
+ /** `SocialPostPreview`: the fold's "more", the not-yet-published timestamp,
70
+ * and the refusals each network's API would answer with. */
71
+ socialPostPreview: SocialPostPreviewLabels;
68
72
  /** `FilterChip` (and `ColumnFilter`): the generic clear affordance, used when
69
73
  * a call site doesn't pass a dimension-specific `clearLabel`. */
70
74
  filterChip: { clear: string };
@@ -348,6 +352,13 @@ export const en: LoticsLocale = {
348
352
  day: "Day", week: "Week", month: "Month", task: "Task",
349
353
  empty: "Nothing scheduled",
350
354
  },
355
+ socialPostPreview: {
356
+ more: "more", justNow: "Just now",
357
+ overLimit: (over) => `${over} over the limit`,
358
+ needsMedia: "Needs a photo or video", tooManyImages: (max) => `Up to ${max} images`,
359
+ videoAlone: "A video goes on its own", gifAlone: "A GIF goes on its own",
360
+ linkOrMedia: "A link or media, not both",
361
+ },
351
362
  filterChip: { clear: "Clear" },
352
363
  filterBand: { filters: "Filters", clearAll: "Clear all", done: "Done" },
353
364
  floatingActionBar: { clear: "Clear" },
@@ -568,6 +579,13 @@ export const vi: LoticsLocale = {
568
579
  day: "Ngày", week: "Tuần", month: "Tháng", task: "Công việc",
569
580
  empty: "Chưa có việc nào",
570
581
  },
582
+ socialPostPreview: {
583
+ more: "xem thêm", justNow: "Vừa xong",
584
+ overLimit: (over) => `Quá ${over} ký tự`,
585
+ needsMedia: "Cần ảnh hoặc video", tooManyImages: (max) => `Tối đa ${max} ảnh`,
586
+ videoAlone: "Video phải đăng riêng", gifAlone: "GIF phải đăng riêng",
587
+ linkOrMedia: "Liên kết hoặc ảnh/video, không cả hai",
588
+ },
571
589
  filterChip: { clear: "Xóa" },
572
590
  filterBand: { filters: "Bộ lọc", clearAll: "Xoá lọc", done: "Xong" },
573
591
  floatingActionBar: { clear: "Bỏ chọn" },
@@ -0,0 +1,187 @@
1
+ import React from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { Avatar } from "./avatar";
4
+ import { BrandMark, type BrandName } from "./brand_mark";
5
+ import { Card } from "./card";
6
+ import { colors } from "./colors";
7
+ import { type DisplayFile } from "./file_thumbnail";
8
+ import { FileThumbnailGrid } from "./file_thumbnail_grid";
9
+ import { MediaPlayer } from "./media_player";
10
+ import { isVideoMimeType } from "./mime";
11
+ import { useLoticsLocale } from "./locale";
12
+ import {
13
+ SOCIAL_NETWORK_RULES,
14
+ countSocialChars,
15
+ socialPostIssueText,
16
+ socialPostIssues,
17
+ type SocialNetwork,
18
+ type SocialNetworkRules,
19
+ type SocialPostDraft,
20
+ type SocialPostLink,
21
+ } from "./social_post_rules";
22
+ import { Text } from "./text";
23
+
24
+ const NETWORK_MARK: Record<SocialNetwork, BrandName> = {
25
+ facebook: "facebook",
26
+ instagram: "instagram",
27
+ threads: "threads",
28
+ x: "x",
29
+ linkedin: "linkedin",
30
+ };
31
+
32
+ export interface SocialPostPreviewProps extends SocialPostDraft {
33
+ network: SocialNetwork;
34
+ /** Who the post goes out as — the name and handle the feed prints. */
35
+ author: { name: string; handle?: string; avatarUrl?: string };
36
+ /** The timestamp line. Defaults to the locale's "just now", which is what a
37
+ * post that has not gone out yet would read. */
38
+ when?: string;
39
+ onFilePress?: (file: DisplayFile) => void;
40
+ testID?: string;
41
+ }
42
+
43
+ /**
44
+ * A post as one network's feed would show it — the author line, the text
45
+ * folded where that feed folds it, the media in the shape that feed lays it out,
46
+ * the link as the card or the plain text it becomes — with the count against
47
+ * the limit and every rule the API would refuse, in words, under it.
48
+ *
49
+ * It is a PREVIEW, so it never writes and never publishes: the app owns the
50
+ * draft and the button. What it owns is the knowledge of what each network
51
+ * does with the same three inputs, which is exactly the thing a person cannot
52
+ * see from an editor and finds out from a refused request otherwise. That
53
+ * knowledge is `social_post_rules`, which the publish gate reads too, so the
54
+ * button refuses exactly what the preview shows.
55
+ */
56
+ export function SocialPostPreview(props: SocialPostPreviewProps) {
57
+ const { network, author, text, media = [], link, when, onFilePress, testID } = props;
58
+ const labels = useLoticsLocale().socialPostPreview;
59
+ const rules = SOCIAL_NETWORK_RULES[network];
60
+ const issues = socialPostIssues(network, { text, media, link });
61
+ const count = countSocialChars(text);
62
+ const over = count > rules.maxChars;
63
+ // A carousel network shows one frame at a time, so a strip that scrolls says
64
+ // "swipe" honestly; a feed that tiles shows the tiles and hides the rest
65
+ // behind a count, which is what the grid's overflow draws.
66
+ const carousel = rules.video === "mixed";
67
+
68
+ return (
69
+ <Card style={styles.card} testID={testID}>
70
+ <View style={styles.header}>
71
+ <Avatar name={author.name} size="md" source={author.avatarUrl ? { uri: author.avatarUrl } : undefined} />
72
+ <View style={styles.identity}>
73
+ <Text size="sm" weight="semibold" numberOfLines={1}>{author.name}</Text>
74
+ <Text size="xs" color="muted" numberOfLines={1}>
75
+ {[author.handle, when ?? labels.justNow].filter(Boolean).join(" · ")}
76
+ </Text>
77
+ </View>
78
+ <BrandMark name={NETWORK_MARK[network]} size={18} />
79
+ </View>
80
+
81
+ <View style={styles.body}>
82
+ {text.length > 0 ? <PostText text={text} rules={rules} more={labels.more} /> : null}
83
+
84
+ {media.length === 1 && isVideoMimeType(media[0].mimeType) ? (
85
+ // A lone video PLAYS in every feed — a tile with a play glyph is what a
86
+ // file list shows, not what the post will look like.
87
+ <View style={styles.video}>
88
+ <MediaPlayer src={media[0].url} kind="video" accessibilityLabel={media[0].filename} />
89
+ </View>
90
+ ) : media.length > 0 ? (
91
+ <FileThumbnailGrid
92
+ files={[...media]}
93
+ columns={carousel ? undefined : Math.min(media.length, 2)}
94
+ itemSize={carousel ? 144 : undefined}
95
+ singleRow={carousel}
96
+ maxVisible={carousel ? undefined : 4}
97
+ onFilePress={onFilePress}
98
+ disablePress={onFilePress === undefined}
99
+ />
100
+ ) : null}
101
+
102
+ {link && rules.link === "card" && (media.length === 0 || rules.linkBesideMedia) ? (
103
+ <LinkCard link={link} />
104
+ ) : null}
105
+ </View>
106
+
107
+ <View style={styles.footer}>
108
+ <Text size="xs" color={over ? "danger" : "muted"} tabular>
109
+ {count} / {rules.maxChars}
110
+ </Text>
111
+ {issues.map((issue) => (
112
+ <Text key={issue.kind} size="xs" color="danger">
113
+ {socialPostIssueText(issue, labels)}
114
+ </Text>
115
+ ))}
116
+ </View>
117
+ </Card>
118
+ );
119
+ }
120
+
121
+ /**
122
+ * The text as the feed shows it: folded at the network's fold with the "more"
123
+ * the reader would tap, and — when it is over the limit — the tail the network
124
+ * would refuse drawn in the danger ink, unfolded, because a fold would hide the
125
+ * very characters the person has to cut.
126
+ */
127
+ function PostText({ text, rules, more }: { text: string; rules: SocialNetworkRules; more: string }) {
128
+ const chars = Array.from(text);
129
+ if (chars.length > rules.maxChars) {
130
+ return (
131
+ <Text size="sm">
132
+ {chars.slice(0, rules.maxChars).join("")}
133
+ <Text size="sm" color="danger">{chars.slice(rules.maxChars).join("")}</Text>
134
+ </Text>
135
+ );
136
+ }
137
+ if (rules.foldChars !== null && chars.length > rules.foldChars) {
138
+ return (
139
+ <Text size="sm">
140
+ {chars.slice(0, rules.foldChars).join("")}…{" "}
141
+ <Text size="sm" color="muted">{more}</Text>
142
+ </Text>
143
+ );
144
+ }
145
+ return <Text size="sm">{text}</Text>;
146
+ }
147
+
148
+ function domainOf(url: string): string {
149
+ try {
150
+ return new URL(url).hostname.replace(/^www\./, "");
151
+ } catch {
152
+ return url;
153
+ }
154
+ }
155
+
156
+ function LinkCard({ link }: { link: SocialPostLink }) {
157
+ return (
158
+ <View style={styles.linkCard}>
159
+ <Text size="xs" color="muted" numberOfLines={1}>{domainOf(link.url)}</Text>
160
+ <Text size="sm" weight="semibold" numberOfLines={2}>{link.title ?? link.url}</Text>
161
+ </View>
162
+ );
163
+ }
164
+
165
+ const styles = StyleSheet.create({
166
+ card: { padding: 0, maxWidth: 520, width: "100%" },
167
+ header: { flexDirection: "row", alignItems: "center", gap: 10, paddingHorizontal: 16, paddingTop: 14 },
168
+ identity: { flex: 1, minWidth: 0 },
169
+ body: { paddingHorizontal: 16, paddingVertical: 12, gap: 12 },
170
+ video: { width: "100%", aspectRatio: 16 / 9, borderRadius: 8, overflow: "hidden", backgroundColor: colors.zinc["900"] },
171
+ linkCard: {
172
+ borderWidth: 1,
173
+ borderColor: colors.zinc["200"],
174
+ borderRadius: 8,
175
+ backgroundColor: colors.zinc["50"],
176
+ paddingHorizontal: 12,
177
+ paddingVertical: 10,
178
+ gap: 2,
179
+ },
180
+ footer: {
181
+ borderTopWidth: 1,
182
+ borderTopColor: colors.zinc["200"],
183
+ paddingHorizontal: 16,
184
+ paddingVertical: 8,
185
+ gap: 2,
186
+ },
187
+ });
@@ -0,0 +1,192 @@
1
+ import type { DisplayFile } from "./file_thumbnail";
2
+
3
+ /**
4
+ * The networks a post can be previewed FOR — the ones whose feed shape and
5
+ * publishing rules this module knows. A surface with no API (a personal Facebook
6
+ * profile, a group, a Zalo OA) has no entry: what it accepts is decided by a
7
+ * person pasting, not by a request the platform can refuse.
8
+ *
9
+ * RN-free on purpose: the publish gate in an app reads this without rendering
10
+ * anything, and the locale packs type their labels off it.
11
+ */
12
+ export const SOCIAL_NETWORKS = ["facebook", "instagram", "threads", "x", "linkedin"] as const;
13
+
14
+ export type SocialNetwork = (typeof SOCIAL_NETWORKS)[number];
15
+
16
+ /**
17
+ * What one network's feed does with a post, and what its API refuses.
18
+ *
19
+ * Two kinds of fact, deliberately in one place: the FOLD (where the feed hides
20
+ * the rest behind "more") is a display fact, the rest are publishing rules the
21
+ * API enforces after the bytes have travelled. Both are what a person needs to
22
+ * see BEFORE pressing publish, so the preview and the gate read one table.
23
+ */
24
+ export interface SocialNetworkRules {
25
+ /** The characters the API accepts. Counted in code points — X weights URLs
26
+ * and CJK differently, so the count is a guide there, not the network's own. */
27
+ maxChars: number;
28
+ /** Where the feed folds the text behind "more", or null when it never does. */
29
+ foldChars: number | null;
30
+ /** The most images one post carries, or null when the API sets no count. */
31
+ maxImages: number | null;
32
+ /** Whether a video has to be the only attachment. */
33
+ video: "alone" | "mixed";
34
+ /** Whether a GIF is its own kind that goes alone (X) or just an image. */
35
+ gif: "alone" | "image";
36
+ /** What a link becomes: a card, or plain text the feed does not open. */
37
+ link: "card" | "text";
38
+ /** Whether the API takes a link beside media, or refuses the pair. */
39
+ linkBesideMedia: boolean;
40
+ /** Whether a post with no media is refused. */
41
+ requiresMedia: boolean;
42
+ }
43
+
44
+ export const SOCIAL_NETWORK_RULES: Record<SocialNetwork, SocialNetworkRules> = {
45
+ facebook: {
46
+ maxChars: 63206,
47
+ foldChars: 477,
48
+ maxImages: null,
49
+ video: "alone",
50
+ gif: "image",
51
+ link: "card",
52
+ linkBesideMedia: false,
53
+ requiresMedia: false,
54
+ },
55
+ instagram: {
56
+ maxChars: 2200,
57
+ foldChars: 125,
58
+ maxImages: 10,
59
+ video: "mixed",
60
+ gif: "image",
61
+ // A URL in a caption is inert text. Drawn as no card, not as a refusal:
62
+ // the API takes it, the reader just cannot tap it.
63
+ link: "text",
64
+ linkBesideMedia: true,
65
+ requiresMedia: true,
66
+ },
67
+ threads: {
68
+ maxChars: 500,
69
+ foldChars: null,
70
+ maxImages: 20,
71
+ video: "mixed",
72
+ gif: "image",
73
+ link: "card",
74
+ linkBesideMedia: false,
75
+ requiresMedia: false,
76
+ },
77
+ x: {
78
+ maxChars: 280,
79
+ foldChars: null,
80
+ maxImages: 4,
81
+ video: "alone",
82
+ gif: "alone",
83
+ link: "card",
84
+ linkBesideMedia: true,
85
+ requiresMedia: false,
86
+ },
87
+ linkedin: {
88
+ maxChars: 3000,
89
+ foldChars: 210,
90
+ maxImages: 20,
91
+ video: "alone",
92
+ gif: "image",
93
+ link: "card",
94
+ linkBesideMedia: false,
95
+ requiresMedia: false,
96
+ },
97
+ };
98
+
99
+ /** The network's own name. Proper nouns, so not in the locale packs. */
100
+ export const SOCIAL_NETWORK_LABELS: Record<SocialNetwork, string> = {
101
+ facebook: "Facebook",
102
+ instagram: "Instagram",
103
+ threads: "Threads",
104
+ x: "X",
105
+ linkedin: "LinkedIn",
106
+ };
107
+
108
+ export interface SocialPostLink {
109
+ url: string;
110
+ title?: string;
111
+ }
112
+
113
+ /** What a post IS before it goes out — the three things every network takes. */
114
+ export interface SocialPostDraft {
115
+ text: string;
116
+ media?: readonly DisplayFile[];
117
+ link?: SocialPostLink | null;
118
+ }
119
+
120
+ export type SocialPostIssue =
121
+ | { kind: "over_limit"; over: number; max: number }
122
+ | { kind: "needs_media" }
123
+ | { kind: "too_many_images"; max: number }
124
+ | { kind: "video_alone" }
125
+ | { kind: "gif_alone" }
126
+ | { kind: "link_or_media" };
127
+
128
+ /** Code points, so a Vietnamese vowel with its tone is one character. */
129
+ export function countSocialChars(text: string): number {
130
+ return Array.from(text).length;
131
+ }
132
+
133
+ function mediaKind(file: DisplayFile): "video" | "gif" | "image" {
134
+ if (file.mimeType.startsWith("video/")) return "video";
135
+ if (file.mimeType === "image/gif") return "gif";
136
+ return "image";
137
+ }
138
+
139
+ /**
140
+ * Everything the network would refuse, from the draft alone — so the gate can
141
+ * close before the bytes travel, and say why in the same words the preview does.
142
+ * An empty list is not a promise the post lands (a token can have expired, a
143
+ * container can fail to process); it is the promise the REQUEST is well-formed.
144
+ */
145
+ export function socialPostIssues(network: SocialNetwork, draft: SocialPostDraft): SocialPostIssue[] {
146
+ const rules = SOCIAL_NETWORK_RULES[network];
147
+ const media = draft.media ?? [];
148
+ const kinds = media.map(mediaKind);
149
+ const videos = kinds.filter((k) => k === "video").length;
150
+ const gifs = kinds.filter((k) => k === "gif").length;
151
+ const images = media.length - videos - (rules.gif === "alone" ? gifs : 0);
152
+ const issues: SocialPostIssue[] = [];
153
+
154
+ const count = countSocialChars(draft.text);
155
+ if (count > rules.maxChars) issues.push({ kind: "over_limit", over: count - rules.maxChars, max: rules.maxChars });
156
+ if (rules.requiresMedia && media.length === 0) issues.push({ kind: "needs_media" });
157
+ if (rules.maxImages !== null && images > rules.maxImages) issues.push({ kind: "too_many_images", max: rules.maxImages });
158
+ if (rules.video === "alone" && videos > 0 && media.length > 1) issues.push({ kind: "video_alone" });
159
+ if (rules.gif === "alone" && gifs > 0 && media.length > 1) issues.push({ kind: "gif_alone" });
160
+ if (draft.link && media.length > 0 && !rules.linkBesideMedia) issues.push({ kind: "link_or_media" });
161
+ return issues;
162
+ }
163
+
164
+ export interface SocialPostPreviewLabels {
165
+ more: string;
166
+ justNow: string;
167
+ overLimit: (over: number) => string;
168
+ needsMedia: string;
169
+ tooManyImages: (max: number) => string;
170
+ videoAlone: string;
171
+ gifAlone: string;
172
+ linkOrMedia: string;
173
+ }
174
+
175
+ /** One issue, in the pack's words — the same sentence beside the preview and on
176
+ * the gate that reads the same list. */
177
+ export function socialPostIssueText(issue: SocialPostIssue, labels: SocialPostPreviewLabels): string {
178
+ switch (issue.kind) {
179
+ case "over_limit":
180
+ return labels.overLimit(issue.over);
181
+ case "needs_media":
182
+ return labels.needsMedia;
183
+ case "too_many_images":
184
+ return labels.tooManyImages(issue.max);
185
+ case "video_alone":
186
+ return labels.videoAlone;
187
+ case "gif_alone":
188
+ return labels.gifAlone;
189
+ case "link_or_media":
190
+ return labels.linkOrMedia;
191
+ }
192
+ }