@colixsystems/widget-sdk 0.149.0 → 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/hooks.js +21 -2
- 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 +2 -2
|
@@ -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/hooks.js
CHANGED
|
@@ -2854,6 +2854,12 @@ export function useAgent(agentRef) {
|
|
|
2854
2854
|
throw err;
|
|
2855
2855
|
}
|
|
2856
2856
|
const turn = await thread.messages(conversationId).send({ content });
|
|
2857
|
+
// A rebind during the round trip makes this the PREVIOUS agent's reply;
|
|
2858
|
+
// writing it now would render it under the new one.
|
|
2859
|
+
if (agentIdRef.current !== agentId) {
|
|
2860
|
+
setSending(false);
|
|
2861
|
+
return turn && turn.reply ? turn.reply : null;
|
|
2862
|
+
}
|
|
2857
2863
|
const userMessage = turn && turn.user_message ? turn.user_message : null;
|
|
2858
2864
|
const reply = turn && turn.reply ? turn.reply : null;
|
|
2859
2865
|
setMessages((prior) =>
|
|
@@ -2866,7 +2872,8 @@ export function useAgent(agentRef) {
|
|
|
2866
2872
|
// A stale thread is recoverable on the next send rather than permanently
|
|
2867
2873
|
// broken: drop it so the widget opens a fresh one.
|
|
2868
2874
|
if (e.code === "CONVERSATION_NOT_FOUND") conversationRef.current = null;
|
|
2869
|
-
|
|
2875
|
+
// Same rebind guard: a new agent must not inherit the old one's error.
|
|
2876
|
+
if (agentIdRef.current === agentId) setError(e);
|
|
2870
2877
|
setSending(false);
|
|
2871
2878
|
throw e;
|
|
2872
2879
|
}
|
|
@@ -2875,13 +2882,25 @@ export function useAgent(agentRef) {
|
|
|
2875
2882
|
// The binding changed this render: hand back the empty transcript now rather
|
|
2876
2883
|
// than one frame of the previous agent's conversation.
|
|
2877
2884
|
let visibleMessages = messages;
|
|
2885
|
+
let visibleError = error;
|
|
2878
2886
|
if (pendingResetRef.current) {
|
|
2879
2887
|
pendingResetRef.current = false;
|
|
2880
2888
|
visibleMessages = [];
|
|
2881
2889
|
if (messages.length > 0) setMessages([]);
|
|
2890
|
+
// The previous agent's failure is not the new one's: leaving it would
|
|
2891
|
+
// report a healthy agent as broken, and widgets gate on error.code.
|
|
2892
|
+
visibleError = null;
|
|
2893
|
+
if (error) setError(null);
|
|
2882
2894
|
}
|
|
2883
2895
|
|
|
2884
|
-
return {
|
|
2896
|
+
return {
|
|
2897
|
+
send,
|
|
2898
|
+
reset,
|
|
2899
|
+
messages: visibleMessages,
|
|
2900
|
+
sending,
|
|
2901
|
+
error: visibleError,
|
|
2902
|
+
available,
|
|
2903
|
+
};
|
|
2885
2904
|
}
|
|
2886
2905
|
|
|
2887
2906
|
/**
|
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 {
|