@colixsystems/widget-sdk 0.149.1 → 0.150.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 +26 -1
- package/dist/contract.cjs +18 -1
- package/dist/contract.js +18 -1
- package/dist/emoji-data.js +1758 -0
- package/dist/emoji-picker-tokens.js +68 -0
- package/dist/emoji-picker-view.js +193 -0
- package/dist/emoji-picker.js +14 -0
- package/dist/emoji-picker.native.js +16 -0
- package/dist/emoji-reactions.js +167 -0
- package/dist/index.js +24 -0
- package/dist/index.native.js +23 -0
- package/dist/linter.cjs +21 -9
- package/dist/linter.js +21 -9
- package/dist/theme-components.cjs +9 -4
- package/dist/theme-components.js +9 -4
- package/package.json +1 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// sc-8104 — the values `<EmojiPicker>` is painted with, and its recents store.
|
|
2
|
+
//
|
|
3
|
+
// Presentation-free like ./overlay-tokens.js: both hosts render the picker
|
|
4
|
+
// from the SAME component, and this is where the theme becomes numbers.
|
|
5
|
+
|
|
6
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
7
|
+
|
|
8
|
+
export const EMOJI_PICKER_DEFAULTS = Object.freeze({
|
|
9
|
+
// Fits a comfortable grid at phone width without shrinking the glyph.
|
|
10
|
+
cellSize: 40,
|
|
11
|
+
glyphSize: 24,
|
|
12
|
+
recentsCap: 24,
|
|
13
|
+
searchLimit: 60,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export function resolveEmojiPickerTokens(theme) {
|
|
17
|
+
const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
|
|
18
|
+
const fallback = DEFAULT_THEME_TOKENS;
|
|
19
|
+
const colors = base.colors || fallback.colors;
|
|
20
|
+
const radii = base.radii || fallback.radii;
|
|
21
|
+
const spacing = base.spacing || fallback.spacing;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
surface: colors.surface || fallback.colors.surface,
|
|
25
|
+
surfaceMuted: colors.surfaceMuted || fallback.colors.surfaceMuted,
|
|
26
|
+
onSurface: colors.onSurface || fallback.colors.onSurface,
|
|
27
|
+
onSurfaceMuted: colors.onSurfaceMuted || fallback.colors.onSurfaceMuted,
|
|
28
|
+
border: colors.border || fallback.colors.border,
|
|
29
|
+
primary: colors.primary || fallback.colors.primary,
|
|
30
|
+
radius: radii.md || fallback.radii.md,
|
|
31
|
+
gap: spacing.sm || fallback.spacing.sm,
|
|
32
|
+
padding: spacing.md || fallback.spacing.md,
|
|
33
|
+
cellSize: EMOJI_PICKER_DEFAULTS.cellSize,
|
|
34
|
+
glyphSize: EMOJI_PICKER_DEFAULTS.glyphSize,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Most-recent-first, de-duplicated, capped. Pure so the host can persist it. */
|
|
39
|
+
export function pushRecentEmoji(recents, emoji, cap = EMOJI_PICKER_DEFAULTS.recentsCap) {
|
|
40
|
+
if (typeof emoji !== "string" || !emoji) return Array.isArray(recents) ? recents : [];
|
|
41
|
+
const rest = (Array.isArray(recents) ? recents : []).filter((e) => e !== emoji);
|
|
42
|
+
return [emoji, ...rest].slice(0, Math.max(0, cap));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Insert text at the caret, returning the new value and where the caret lands.
|
|
47
|
+
* A composer inserts mid-draft rather than appending, so the selection has to
|
|
48
|
+
* move with the insertion.
|
|
49
|
+
*/
|
|
50
|
+
export function insertAtCaret(value, insertion, selection) {
|
|
51
|
+
const text = typeof value === "string" ? value : "";
|
|
52
|
+
const add = typeof insertion === "string" ? insertion : "";
|
|
53
|
+
const rawStart = selection?.start;
|
|
54
|
+
const rawEnd = selection?.end;
|
|
55
|
+
const start = clampIndex(rawStart, text.length, text.length);
|
|
56
|
+
const end = clampIndex(rawEnd, text.length, start);
|
|
57
|
+
const from = Math.min(start, end);
|
|
58
|
+
const to = Math.max(start, end);
|
|
59
|
+
return {
|
|
60
|
+
value: text.slice(0, from) + add + text.slice(to),
|
|
61
|
+
caret: from + add.length,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function clampIndex(value, max, fallback) {
|
|
66
|
+
if (!Number.isFinite(value)) return fallback;
|
|
67
|
+
return Math.min(Math.max(0, Math.trunc(value)), max);
|
|
68
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// sc-8104 — the ONE implementation behind the `<EmojiPicker>` SDK primitive.
|
|
2
|
+
//
|
|
3
|
+
// Typed emoji already worked on both hosts; what forced users out to the OS
|
|
4
|
+
// keyboard was having no in-app way to PICK one. The picker serves both the
|
|
5
|
+
// composer (insert into a draft) and the reaction flow (start a new emoji
|
|
6
|
+
// group), so it is one shared component bound per host the way `<Overlay>` is
|
|
7
|
+
// (./overlay-view.js) — the hosts cannot drift (CLAUDE.md §3, §8).
|
|
8
|
+
//
|
|
9
|
+
// It renders INSIDE the SDK `Overlay`, which already escapes widget clipping
|
|
10
|
+
// on both hosts, so a picker opened from a card is never cut off.
|
|
11
|
+
|
|
12
|
+
import React from "react";
|
|
13
|
+
import { useHostTheme } from "./hooks.js";
|
|
14
|
+
import { resolveEmojiPickerTokens, EMOJI_PICKER_DEFAULTS, pushRecentEmoji } from "./emoji-picker-tokens.js";
|
|
15
|
+
import { EMOJI_CATEGORIES, emojiForCategory, searchEmoji } from "./emoji-data.js";
|
|
16
|
+
|
|
17
|
+
const RECENTS_KEY = "recent";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Bind the shared emoji picker to one platform's primitives.
|
|
21
|
+
*
|
|
22
|
+
* @param {{Overlay, View, Text, Pressable, ScrollView, TextInput}} rn
|
|
23
|
+
*/
|
|
24
|
+
export function makeEmojiPicker(rn) {
|
|
25
|
+
const { Overlay, View, Text, Pressable, ScrollView, TextInput } = rn;
|
|
26
|
+
|
|
27
|
+
function EmojiPicker({
|
|
28
|
+
visible = false,
|
|
29
|
+
onSelect,
|
|
30
|
+
onRequestClose,
|
|
31
|
+
recents,
|
|
32
|
+
onRecentsChange,
|
|
33
|
+
accessibilityLabel = "Choose an emoji",
|
|
34
|
+
closeAccessibilityLabel = "Close the emoji picker",
|
|
35
|
+
searchPlaceholder = "Search emoji",
|
|
36
|
+
}) {
|
|
37
|
+
const theme = useHostTheme();
|
|
38
|
+
const tokens = React.useMemo(() => resolveEmojiPickerTokens(theme), [theme]);
|
|
39
|
+
const [query, setQuery] = React.useState("");
|
|
40
|
+
const [category, setCategory] = React.useState(EMOJI_CATEGORIES[0].key);
|
|
41
|
+
|
|
42
|
+
const safeRecents = React.useMemo(
|
|
43
|
+
() => (Array.isArray(recents) ? recents.filter((e) => typeof e === "string" && e) : []),
|
|
44
|
+
[recents],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Reopening with a stale query would show yesterday's search, not the grid.
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
if (!visible) return;
|
|
50
|
+
setQuery("");
|
|
51
|
+
setCategory(safeRecents.length ? RECENTS_KEY : EMOJI_CATEGORIES[0].key);
|
|
52
|
+
}, [visible, safeRecents.length]);
|
|
53
|
+
|
|
54
|
+
const trimmed = query.trim();
|
|
55
|
+
const results = React.useMemo(() => {
|
|
56
|
+
if (trimmed) return searchEmoji(trimmed, EMOJI_PICKER_DEFAULTS.searchLimit);
|
|
57
|
+
if (category === RECENTS_KEY) return safeRecents.map((char) => ({ char, name: char }));
|
|
58
|
+
return emojiForCategory(category);
|
|
59
|
+
}, [trimmed, category, safeRecents]);
|
|
60
|
+
|
|
61
|
+
const handleSelect = React.useCallback(
|
|
62
|
+
(char) => {
|
|
63
|
+
if (typeof onRecentsChange === "function") {
|
|
64
|
+
onRecentsChange(pushRecentEmoji(safeRecents, char));
|
|
65
|
+
}
|
|
66
|
+
if (typeof onSelect === "function") onSelect(char);
|
|
67
|
+
},
|
|
68
|
+
[onSelect, onRecentsChange, safeRecents],
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const tabs = safeRecents.length
|
|
72
|
+
? [{ key: RECENTS_KEY, label: "Recent" }, ...EMOJI_CATEGORIES]
|
|
73
|
+
: EMOJI_CATEGORIES;
|
|
74
|
+
|
|
75
|
+
return React.createElement(
|
|
76
|
+
Overlay,
|
|
77
|
+
{
|
|
78
|
+
visible,
|
|
79
|
+
onRequestClose,
|
|
80
|
+
size: "md",
|
|
81
|
+
accessibilityLabel,
|
|
82
|
+
closeAccessibilityLabel,
|
|
83
|
+
},
|
|
84
|
+
React.createElement(
|
|
85
|
+
View,
|
|
86
|
+
{ style: { gap: tokens.gap }, testID: "emoji-picker" },
|
|
87
|
+
React.createElement(TextInput, {
|
|
88
|
+
testID: "emoji-picker-search",
|
|
89
|
+
value: query,
|
|
90
|
+
onChangeText: setQuery,
|
|
91
|
+
placeholder: searchPlaceholder,
|
|
92
|
+
placeholderTextColor: tokens.onSurfaceMuted,
|
|
93
|
+
accessibilityLabel: searchPlaceholder,
|
|
94
|
+
style: {
|
|
95
|
+
color: tokens.onSurface,
|
|
96
|
+
backgroundColor: tokens.surfaceMuted,
|
|
97
|
+
borderRadius: tokens.radius,
|
|
98
|
+
paddingHorizontal: tokens.padding,
|
|
99
|
+
paddingVertical: tokens.gap,
|
|
100
|
+
fontSize: 14,
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
|
|
104
|
+
// Category tabs are hidden while searching: results already span them.
|
|
105
|
+
trimmed
|
|
106
|
+
? null
|
|
107
|
+
: React.createElement(
|
|
108
|
+
ScrollView,
|
|
109
|
+
{ horizontal: true, showsHorizontalScrollIndicator: false, testID: "emoji-picker-tabs" },
|
|
110
|
+
React.createElement(
|
|
111
|
+
View,
|
|
112
|
+
{ style: { flexDirection: "row", gap: tokens.gap } },
|
|
113
|
+
tabs.map((tab) =>
|
|
114
|
+
React.createElement(
|
|
115
|
+
Pressable,
|
|
116
|
+
{
|
|
117
|
+
key: tab.key,
|
|
118
|
+
testID: `emoji-picker-tab-${tab.key}`,
|
|
119
|
+
onPress: () => setCategory(tab.key),
|
|
120
|
+
accessibilityRole: "tab",
|
|
121
|
+
accessibilityLabel: tab.label,
|
|
122
|
+
"aria-selected": category === tab.key,
|
|
123
|
+
style: {
|
|
124
|
+
paddingHorizontal: tokens.padding,
|
|
125
|
+
paddingVertical: tokens.gap,
|
|
126
|
+
borderRadius: tokens.radius,
|
|
127
|
+
backgroundColor:
|
|
128
|
+
category === tab.key ? tokens.surfaceMuted : "transparent",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
React.createElement(
|
|
132
|
+
Text,
|
|
133
|
+
{
|
|
134
|
+
style: {
|
|
135
|
+
fontSize: 12,
|
|
136
|
+
fontWeight: "600",
|
|
137
|
+
color: category === tab.key ? tokens.primary : tokens.onSurfaceMuted,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
tab.label,
|
|
141
|
+
),
|
|
142
|
+
),
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
),
|
|
146
|
+
|
|
147
|
+
React.createElement(
|
|
148
|
+
ScrollView,
|
|
149
|
+
{ style: { maxHeight: 280 }, testID: "emoji-picker-grid" },
|
|
150
|
+
results.length
|
|
151
|
+
? React.createElement(
|
|
152
|
+
View,
|
|
153
|
+
{ style: { flexDirection: "row", flexWrap: "wrap" } },
|
|
154
|
+
results.map((entry) =>
|
|
155
|
+
React.createElement(
|
|
156
|
+
Pressable,
|
|
157
|
+
{
|
|
158
|
+
key: entry.char,
|
|
159
|
+
testID: `emoji-picker-option-${entry.char}`,
|
|
160
|
+
onPress: () => handleSelect(entry.char),
|
|
161
|
+
accessibilityRole: "button",
|
|
162
|
+
accessibilityLabel: entry.name || entry.char,
|
|
163
|
+
style: {
|
|
164
|
+
width: tokens.cellSize,
|
|
165
|
+
height: tokens.cellSize,
|
|
166
|
+
alignItems: "center",
|
|
167
|
+
justifyContent: "center",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
React.createElement(
|
|
171
|
+
Text,
|
|
172
|
+
{ style: { fontSize: tokens.glyphSize, lineHeight: tokens.cellSize } },
|
|
173
|
+
entry.char,
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
: React.createElement(
|
|
179
|
+
Text,
|
|
180
|
+
{
|
|
181
|
+
testID: "emoji-picker-empty",
|
|
182
|
+
style: { color: tokens.onSurfaceMuted, fontSize: 13, padding: tokens.padding },
|
|
183
|
+
},
|
|
184
|
+
"No emoji found",
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
EmojiPicker.displayName = "EmojiPicker";
|
|
192
|
+
return EmojiPicker;
|
|
193
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// sc-8104 — `<EmojiPicker>` (web). Binds the shared implementation in
|
|
2
|
+
// ./emoji-picker-view.js to react-native-web's primitives plus the web
|
|
3
|
+
// `<Overlay>`, so the picker escapes the widget's clipping ancestors the same
|
|
4
|
+
// way every other SDK overlay does.
|
|
5
|
+
//
|
|
6
|
+
// The import is `react-native-web` rather than the bare `react-native`
|
|
7
|
+
// specifier for the rolldown optional-peer-dep reason at the top of
|
|
8
|
+
// ./primitives.js.
|
|
9
|
+
|
|
10
|
+
import * as ReactNative from "react-native-web";
|
|
11
|
+
import { makeEmojiPicker } from "./emoji-picker-view.js";
|
|
12
|
+
import { Overlay } from "./overlay.js";
|
|
13
|
+
|
|
14
|
+
export const EmojiPicker = makeEmojiPicker({ ...ReactNative, Overlay });
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// sc-8104 — `<EmojiPicker>` (native). Binds the shared implementation in
|
|
2
|
+
// ./emoji-picker-view.js to React Native's own primitives plus the native
|
|
3
|
+
// `<Overlay>`. Only the imports differ from the web mirror (./emoji-picker.js).
|
|
4
|
+
|
|
5
|
+
import { View, Text, Pressable, ScrollView, TextInput } from "react-native";
|
|
6
|
+
import { makeEmojiPicker } from "./emoji-picker-view.js";
|
|
7
|
+
import { Overlay } from "./overlay.native.js";
|
|
8
|
+
|
|
9
|
+
export const EmojiPicker = makeEmojiPicker({
|
|
10
|
+
View,
|
|
11
|
+
Text,
|
|
12
|
+
Pressable,
|
|
13
|
+
ScrollView,
|
|
14
|
+
TextInput,
|
|
15
|
+
Overlay,
|
|
16
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// sc-8104 — the ONE reaction model behind Slack-style emoji reactions.
|
|
2
|
+
//
|
|
3
|
+
// Newsfeed and Chat both render per-emoji pills over rows of a Reactions
|
|
4
|
+
// table, so the grouping, the self-reaction rule and the legacy-slug mapping
|
|
5
|
+
// live here once rather than in each widget (CLAUDE.md §3). Presentation-free
|
|
6
|
+
// and platform-free like ./overlay-tokens.js: the maths is unit-testable
|
|
7
|
+
// without a renderer, and both hosts render it from the same numbers (§8).
|
|
8
|
+
|
|
9
|
+
import { isEmojiChar } from "./emoji-data.js";
|
|
10
|
+
|
|
11
|
+
// Reactions shipped before this as five slugs plus the bare pre-set-era
|
|
12
|
+
// `like`. Production holds those rows, so they are mapped at READ time and
|
|
13
|
+
// keep counting; no backfill, and writes are emoji from here on (§7).
|
|
14
|
+
export const LEGACY_REACTION_EMOJI = Object.freeze({
|
|
15
|
+
like: "👍",
|
|
16
|
+
love: "❤️",
|
|
17
|
+
laugh: "😂",
|
|
18
|
+
celebrate: "🎉",
|
|
19
|
+
sad: "😢",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Looked up through a Map, never the object above: a stored "constructor" or
|
|
23
|
+
// "__proto__" would resolve off Object.prototype and be counted as a reaction,
|
|
24
|
+
// rendering a function as a React child and crashing the whole list.
|
|
25
|
+
const LEGACY_BY_SLUG = new Map(Object.entries(LEGACY_REACTION_EMOJI));
|
|
26
|
+
|
|
27
|
+
// What a plain tap writes, and what every legacy `like` row maps onto.
|
|
28
|
+
export const DEFAULT_REACTION_EMOJI = LEGACY_REACTION_EMOJI.like;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The emoji a stored Type value stands for, or null when it is neither a
|
|
32
|
+
* legacy slug nor an emoji we ship — a hand-edited value must not be counted.
|
|
33
|
+
*/
|
|
34
|
+
export function readReactionEmoji(value) {
|
|
35
|
+
if (typeof value !== "string" || !value) return null;
|
|
36
|
+
const legacy = LEGACY_BY_SLUG.get(value);
|
|
37
|
+
if (legacy) return legacy;
|
|
38
|
+
return isEmojiChar(value) ? value : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether a stored Type value counts at all. */
|
|
42
|
+
export function isCountableReaction(value) {
|
|
43
|
+
return readReactionEmoji(value) !== null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The emoji a stored ROW stands for. An unbound type column means every row is
|
|
48
|
+
* a plain default reaction, which is what rows written before the type set
|
|
49
|
+
* existed look like.
|
|
50
|
+
*/
|
|
51
|
+
export function rowReactionEmoji(row, typeField) {
|
|
52
|
+
if (!typeField) return DEFAULT_REACTION_EMOJI;
|
|
53
|
+
return readReactionEmoji(row?.[typeField]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether one row is this user's reaction with this emoji — the set an
|
|
58
|
+
* un-react must clear, and the single predicate deciding add-vs-remove.
|
|
59
|
+
*
|
|
60
|
+
* Shared because each widget deriving it separately is how they drift: read
|
|
61
|
+
* against the ROW's stored value, never against the tapped emoji, or an
|
|
62
|
+
* unbound type column makes every row match whatever was tapped and the tap
|
|
63
|
+
* silently deletes an unrelated reaction (CLAUDE.md §3).
|
|
64
|
+
*/
|
|
65
|
+
export function isMyReactionRow(row, { typeField, userField, userId, emoji }) {
|
|
66
|
+
if (!userId || !emoji) return false;
|
|
67
|
+
if (readRelationId(row?.[userField]) !== userId) return false;
|
|
68
|
+
return rowReactionEmoji(row, typeField) === emoji;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Group reaction rows into per-emoji pills.
|
|
73
|
+
*
|
|
74
|
+
* One row per (target, user, emoji) is the invariant: a user may hold several
|
|
75
|
+
* DIFFERENT emoji on one target but never two rows of the same one, so each
|
|
76
|
+
* group de-duplicates by user. `mine` keeps every one of the current user's
|
|
77
|
+
* rows for that emoji, which is what lets an un-react clear duplicates a table
|
|
78
|
+
* already picked up.
|
|
79
|
+
*
|
|
80
|
+
* @returns {Array<{emoji, count, reactedByMe, userIds, rows, mine}>}
|
|
81
|
+
* in descending count, ties broken by first appearance.
|
|
82
|
+
*/
|
|
83
|
+
export function groupReactions(rows, { typeField, userField, userId } = {}) {
|
|
84
|
+
const groups = new Map();
|
|
85
|
+
let order = 0;
|
|
86
|
+
|
|
87
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
88
|
+
// An unbound Type column means every row is a plain like, which is what
|
|
89
|
+
// rows written before the type set existed look like.
|
|
90
|
+
const stored = typeField ? row?.[typeField] : DEFAULT_REACTION_EMOJI;
|
|
91
|
+
const emoji = readReactionEmoji(stored);
|
|
92
|
+
if (!emoji) continue;
|
|
93
|
+
|
|
94
|
+
const rowUserId = readRelationId(row?.[userField]);
|
|
95
|
+
let group = groups.get(emoji);
|
|
96
|
+
if (!group) {
|
|
97
|
+
group = { emoji, order: order++, userIds: [], seen: new Set(), rows: [], mine: [] };
|
|
98
|
+
groups.set(emoji, group);
|
|
99
|
+
}
|
|
100
|
+
group.rows.push(row);
|
|
101
|
+
if (rowUserId && userId && rowUserId === userId) group.mine.push(row);
|
|
102
|
+
if (!rowUserId || group.seen.has(rowUserId)) continue;
|
|
103
|
+
group.seen.add(rowUserId);
|
|
104
|
+
group.userIds.push(rowUserId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return [...groups.values()]
|
|
108
|
+
.map((group) => ({
|
|
109
|
+
emoji: group.emoji,
|
|
110
|
+
count: group.userIds.length,
|
|
111
|
+
reactedByMe: group.mine.length > 0,
|
|
112
|
+
userIds: group.userIds,
|
|
113
|
+
rows: group.rows,
|
|
114
|
+
mine: group.mine,
|
|
115
|
+
order: group.order,
|
|
116
|
+
}))
|
|
117
|
+
.filter((group) => group.count > 0)
|
|
118
|
+
.sort((a, b) => b.count - a.count || a.order - b.order)
|
|
119
|
+
.map(({ order: _order, ...group }) => group);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The groups a tap should produce before the write lands, so the pill moves on
|
|
124
|
+
* the tap rather than on the refetch. `pending` is a Map of emoji → boolean:
|
|
125
|
+
* true means "mine is being added", false "mine is being removed".
|
|
126
|
+
*/
|
|
127
|
+
export function applyPendingReactions(groups, pending, userId) {
|
|
128
|
+
if (!pending || pending.size === 0) return groups;
|
|
129
|
+
const byEmoji = new Map(groups.map((group) => [group.emoji, { ...group }]));
|
|
130
|
+
|
|
131
|
+
for (const [emoji, adding] of pending) {
|
|
132
|
+
const group = byEmoji.get(emoji);
|
|
133
|
+
if (adding) {
|
|
134
|
+
if (!group) {
|
|
135
|
+
byEmoji.set(emoji, {
|
|
136
|
+
emoji,
|
|
137
|
+
count: 1,
|
|
138
|
+
reactedByMe: true,
|
|
139
|
+
userIds: userId ? [userId] : [],
|
|
140
|
+
rows: [],
|
|
141
|
+
mine: [],
|
|
142
|
+
});
|
|
143
|
+
} else if (!group.reactedByMe) {
|
|
144
|
+
group.reactedByMe = true;
|
|
145
|
+
group.count += 1;
|
|
146
|
+
group.userIds = userId ? [...group.userIds, userId] : group.userIds;
|
|
147
|
+
}
|
|
148
|
+
} else if (group?.reactedByMe) {
|
|
149
|
+
group.reactedByMe = false;
|
|
150
|
+
group.count = Math.max(0, group.count - 1);
|
|
151
|
+
group.userIds = group.userIds.filter((id) => id !== userId);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return [...byEmoji.values()]
|
|
156
|
+
.filter((group) => group.count > 0)
|
|
157
|
+
.sort((a, b) => b.count - a.count);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readRelationId(value) {
|
|
161
|
+
if (!value) return null;
|
|
162
|
+
if (typeof value === "string") return value || null;
|
|
163
|
+
if (typeof value === "object" && typeof value.id === "string") {
|
|
164
|
+
return value.id || null;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -101,6 +101,9 @@ export { useToast } from "./toast.js";
|
|
|
101
101
|
// each platform's primitives, so a widget's modal escapes its container box
|
|
102
102
|
// identically on the Player and the Expo export.
|
|
103
103
|
export { Overlay } from "./overlay.js";
|
|
104
|
+
// sc-8104 — `<EmojiPicker>` is bound the same way, so the in-app picker that
|
|
105
|
+
// serves both composers and the reaction flow renders identically on both hosts.
|
|
106
|
+
export { EmojiPicker } from "./emoji-picker.js";
|
|
104
107
|
// sc-6970 — formatted content. `<RichText>` renders the markdown subset and
|
|
105
108
|
// `<MarkdownInput>` authors it; widgets have no HTML path on either host, so
|
|
106
109
|
// these are the ONE way content with bold/headings/lists is written and shown.
|
|
@@ -158,6 +161,27 @@ export {
|
|
|
158
161
|
clampSpacingScale,
|
|
159
162
|
scaleSpacing,
|
|
160
163
|
} from "./contract.js";
|
|
164
|
+
// sc-8104 — the curated emoji set ships WITH the SDK (plain string data, no
|
|
165
|
+
// dependency), and the reaction model is shared so Newsfeed and Chat group
|
|
166
|
+
// and migrate reactions identically.
|
|
167
|
+
export {
|
|
168
|
+
EMOJI_CATEGORIES,
|
|
169
|
+
EMOJI_LIST,
|
|
170
|
+
searchEmoji,
|
|
171
|
+
emojiForCategory,
|
|
172
|
+
isEmojiChar,
|
|
173
|
+
} from "./emoji-data.js";
|
|
174
|
+
export {
|
|
175
|
+
LEGACY_REACTION_EMOJI,
|
|
176
|
+
DEFAULT_REACTION_EMOJI,
|
|
177
|
+
readReactionEmoji,
|
|
178
|
+
isCountableReaction,
|
|
179
|
+
rowReactionEmoji,
|
|
180
|
+
isMyReactionRow,
|
|
181
|
+
groupReactions,
|
|
182
|
+
applyPendingReactions,
|
|
183
|
+
} from "./emoji-reactions.js";
|
|
184
|
+
export { insertAtCaret, pushRecentEmoji } from "./emoji-picker-tokens.js";
|
|
161
185
|
export { normalizeLucideIconName } from "./lucideIconName.js";
|
|
162
186
|
export { pressableLift } from "./interaction.js";
|
|
163
187
|
export { styleGroup } from "./style-group.js";
|
package/dist/index.native.js
CHANGED
|
@@ -97,6 +97,8 @@ export { useClipboard, ClipboardError } from "./clipboard.native.js";
|
|
|
97
97
|
export { useToast } from "./toast.native.js";
|
|
98
98
|
// sc-6607 — see the note on the web mirror in ./index.js.
|
|
99
99
|
export { Overlay } from "./overlay.native.js";
|
|
100
|
+
// sc-8104 — see the note on the web mirror in ./index.js.
|
|
101
|
+
export { EmojiPicker } from "./emoji-picker.native.js";
|
|
100
102
|
// sc-6970 — see the note on the web mirror in ./index.js.
|
|
101
103
|
export { RichText } from "./richtext.native.js";
|
|
102
104
|
export { MarkdownInput } from "./markdown-input.native.js";
|
|
@@ -152,6 +154,27 @@ export {
|
|
|
152
154
|
clampSpacingScale,
|
|
153
155
|
scaleSpacing,
|
|
154
156
|
} from "./contract.js";
|
|
157
|
+
// sc-8104 — the curated emoji set ships WITH the SDK (plain string data, no
|
|
158
|
+
// dependency), and the reaction model is shared so Newsfeed and Chat group
|
|
159
|
+
// and migrate reactions identically.
|
|
160
|
+
export {
|
|
161
|
+
EMOJI_CATEGORIES,
|
|
162
|
+
EMOJI_LIST,
|
|
163
|
+
searchEmoji,
|
|
164
|
+
emojiForCategory,
|
|
165
|
+
isEmojiChar,
|
|
166
|
+
} from "./emoji-data.js";
|
|
167
|
+
export {
|
|
168
|
+
LEGACY_REACTION_EMOJI,
|
|
169
|
+
DEFAULT_REACTION_EMOJI,
|
|
170
|
+
readReactionEmoji,
|
|
171
|
+
isCountableReaction,
|
|
172
|
+
rowReactionEmoji,
|
|
173
|
+
isMyReactionRow,
|
|
174
|
+
groupReactions,
|
|
175
|
+
applyPendingReactions,
|
|
176
|
+
} from "./emoji-reactions.js";
|
|
177
|
+
export { insertAtCaret, pushRecentEmoji } from "./emoji-picker-tokens.js";
|
|
155
178
|
export { normalizeLucideIconName } from "./lucideIconName.js";
|
|
156
179
|
export { pressableLift } from "./interaction.native.js";
|
|
157
180
|
export { styleGroup } from "./style-group.js";
|
package/dist/linter.cjs
CHANGED
|
@@ -567,13 +567,13 @@ function _scopeRules(source, manifest) {
|
|
|
567
567
|
}
|
|
568
568
|
|
|
569
569
|
// sc-7991 — every send spends the workspace's AI credits, so the manifest has
|
|
570
|
-
// to declare the capability. This gate is ADVISORY
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
570
|
+
// to declare the capability. This gate is still ADVISORY: the scope itself is
|
|
571
|
+
// not minted into X-Widget-Scopes, so a widget that evades this regex still
|
|
572
|
+
// reaches the endpoint, and the scope's real job is DISCLOSURE — it is what
|
|
573
|
+
// the marketplace install consent screen shows.
|
|
574
|
+
// sc-8128 — but the endpoint is no longer unguarded: a turn is refused unless
|
|
575
|
+
// the agent is bound on a page the caller can read. Abuse is bounded by that,
|
|
576
|
+
// the app-user JWT, the per-actor and per-tenant limiters and the AI quota.
|
|
577
577
|
if (/\buseAgent\s*\(/.test(source)) {
|
|
578
578
|
const invokes = declared.has("ai.invoke:*") || declared.has("ai.invoke");
|
|
579
579
|
if (!invokes) {
|
|
@@ -1596,6 +1596,19 @@ function _bracedSpan(code, from) {
|
|
|
1596
1596
|
return null;
|
|
1597
1597
|
}
|
|
1598
1598
|
|
|
1599
|
+
// The non-escape branch excludes `\` so the two branches can never both
|
|
1600
|
+
// match the same character. With a bare `.`, escape pairs before a quote
|
|
1601
|
+
// the anchor then rejects backtrack exponentially — 60 characters of an
|
|
1602
|
+
// authored widget draft wedge the single-threaded API pod for ~60s, on the
|
|
1603
|
+
// AI-widget-agent publish vet, not the package-upload path (sc-8051, sc-8041).
|
|
1604
|
+
//
|
|
1605
|
+
// It excludes the line terminators too, because a character class — unlike
|
|
1606
|
+
// `.` — matches them, which would widen the literal past its own line.
|
|
1607
|
+
// An odd trailing backslash run therefore no longer matches; `_bracedSpan`
|
|
1608
|
+
// shares the escape rule and never yields one.
|
|
1609
|
+
const _TEXT_LITERAL_CHILD_RE =
|
|
1610
|
+
/^(["'`])((?:\\.|(?!\1)[^\\\r\n\u2028\u2029])*)\1$/;
|
|
1611
|
+
|
|
1599
1612
|
// Copy pinned in a `<Text>`: a bare literal child, or a braced child that is
|
|
1600
1613
|
// NOTHING BUT a string literal (`{"Hello"}`) — the one-pair-of-braces evasion.
|
|
1601
1614
|
// A braced expression carrying an identifier is left alone: it may already be
|
|
@@ -1618,8 +1631,7 @@ function _textLiteralOccurrences(codes, masks) {
|
|
|
1618
1631
|
let text = null;
|
|
1619
1632
|
if (code[i] === "{") {
|
|
1620
1633
|
const inner = _bracedSpan(code, i);
|
|
1621
|
-
const lit =
|
|
1622
|
-
inner && inner.trim().match(/^(["'`])((?:\\.|(?!\1).)*)\1$/);
|
|
1634
|
+
const lit = inner && inner.trim().match(_TEXT_LITERAL_CHILD_RE);
|
|
1623
1635
|
// A template carrying `${…}` interpolates a value, so it is not a literal.
|
|
1624
1636
|
if (lit && !(lit[1] === "`" && lit[2].includes("${"))) text = lit[2];
|
|
1625
1637
|
} else {
|
package/dist/linter.js
CHANGED
|
@@ -530,13 +530,13 @@ function _scopeRules(source, manifest) {
|
|
|
530
530
|
}
|
|
531
531
|
|
|
532
532
|
// sc-7991 — every send spends the workspace's AI credits, so the manifest has
|
|
533
|
-
// to declare the capability. This gate is ADVISORY
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
533
|
+
// to declare the capability. This gate is still ADVISORY: the scope itself is
|
|
534
|
+
// not minted into X-Widget-Scopes, so a widget that evades this regex still
|
|
535
|
+
// reaches the endpoint, and the scope's real job is DISCLOSURE — it is what
|
|
536
|
+
// the marketplace install consent screen shows.
|
|
537
|
+
// sc-8128 — but the endpoint is no longer unguarded: a turn is refused unless
|
|
538
|
+
// the agent is bound on a page the caller can read. Abuse is bounded by that,
|
|
539
|
+
// the app-user JWT, the per-actor and per-tenant limiters and the AI quota.
|
|
540
540
|
if (/\buseAgent\s*\(/.test(source)) {
|
|
541
541
|
const invokes = declared.has("ai.invoke:*") || declared.has("ai.invoke");
|
|
542
542
|
if (!invokes) {
|
|
@@ -1667,6 +1667,19 @@ function _bracedSpan(code, from) {
|
|
|
1667
1667
|
return null;
|
|
1668
1668
|
}
|
|
1669
1669
|
|
|
1670
|
+
// The non-escape branch excludes `\` so the two branches can never both
|
|
1671
|
+
// match the same character. With a bare `.`, escape pairs before a quote
|
|
1672
|
+
// the anchor then rejects backtrack exponentially — 60 characters of an
|
|
1673
|
+
// authored widget draft wedge the single-threaded API pod for ~60s, on the
|
|
1674
|
+
// AI-widget-agent publish vet, not the package-upload path (sc-8051, sc-8041).
|
|
1675
|
+
//
|
|
1676
|
+
// It excludes the line terminators too, because a character class — unlike
|
|
1677
|
+
// `.` — matches them, which would widen the literal past its own line.
|
|
1678
|
+
// An odd trailing backslash run therefore no longer matches; `_bracedSpan`
|
|
1679
|
+
// shares the escape rule and never yields one.
|
|
1680
|
+
const _TEXT_LITERAL_CHILD_RE =
|
|
1681
|
+
/^(["'`])((?:\\.|(?!\1)[^\\\r\n\u2028\u2029])*)\1$/;
|
|
1682
|
+
|
|
1670
1683
|
// Copy pinned in a `<Text>`: a bare literal child, or a braced child that is
|
|
1671
1684
|
// NOTHING BUT a string literal (`{"Hello"}`) — the one-pair-of-braces evasion.
|
|
1672
1685
|
// A braced expression carrying an identifier is left alone: it may already be
|
|
@@ -1689,8 +1702,7 @@ function _textLiteralOccurrences(codes, masks) {
|
|
|
1689
1702
|
let text = null;
|
|
1690
1703
|
if (code[i] === "{") {
|
|
1691
1704
|
const inner = _bracedSpan(code, i);
|
|
1692
|
-
const lit =
|
|
1693
|
-
inner && inner.trim().match(/^(["'`])((?:\\.|(?!\1).)*)\1$/);
|
|
1705
|
+
const lit = inner && inner.trim().match(_TEXT_LITERAL_CHILD_RE);
|
|
1694
1706
|
// A template carrying `${…}` interpolates a value, so it is not a literal.
|
|
1695
1707
|
if (lit && !(lit[1] === "`" && lit[2].includes("${"))) text = lit[2];
|
|
1696
1708
|
} else {
|
|
@@ -99,6 +99,11 @@ function normaliseThemeComponents(raw) {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
|
|
102
|
+
// Long enough for a hex, an enum value or a font name; short enough that a
|
|
103
|
+
// hand-edited theme_config cannot smuggle a payload into every widget.
|
|
104
|
+
// Named because the Mason plan gate prints it when it rejects a value.
|
|
105
|
+
const MAX_STYLE_STRING_LENGTH = 64;
|
|
106
|
+
|
|
102
107
|
// Values are assigned onto plain object literals, so `__proto__` would set a
|
|
103
108
|
// PROTOTYPE rather than a style field. Refused at every level.
|
|
104
109
|
const UNSAFE_STYLE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
@@ -125,9 +130,9 @@ function coerceWidgetStyleValue(value, depth = 0) {
|
|
|
125
130
|
const { maxValueDepth, maxValueEntries } = CONTRACT.themeWidgetStyles;
|
|
126
131
|
if (typeof value === "string") {
|
|
127
132
|
const trimmed = value.trim();
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
133
|
+
return trimmed && trimmed.length <= MAX_STYLE_STRING_LENGTH
|
|
134
|
+
? trimmed
|
|
135
|
+
: undefined;
|
|
131
136
|
}
|
|
132
137
|
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
133
138
|
if (typeof value === "boolean") return value;
|
|
@@ -393,4 +398,4 @@ function applyThemeComponentStyle(manifestId, theme, props, styleSchema) {
|
|
|
393
398
|
return { ...base, style: { ...themed, ...authored } };
|
|
394
399
|
}
|
|
395
400
|
|
|
396
|
-
module.exports = { normaliseThemeComponents, normaliseWidgetStyleFields, normaliseWidgetStyles, applyThemeComponentStyle };
|
|
401
|
+
module.exports = { normaliseThemeComponents, normaliseWidgetStyleFields, normaliseWidgetStyles, applyThemeComponentStyle, styleValueBytes, MAX_STYLE_STRING_LENGTH };
|