@vellumai/cli 0.11.10 → 0.11.11-staging.2
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/node_modules/@vellumai/avatar-manifest/package.json +2 -1
- package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +220 -0
- package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +139 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/package.json +2 -1
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +220 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +139 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/reactions.ts +85 -5
- package/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
- package/node_modules/@vellumai/service-contracts/src/reactions.ts +85 -5
- package/package.json +1 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
NOTIFICATION_AVATAR_FALLBACK_DISC_HEX,
|
|
5
|
+
NOTIFICATION_AVATAR_INSET,
|
|
6
|
+
NOTIFICATION_AVATAR_MAX_BYTES,
|
|
7
|
+
NOTIFICATION_AVATAR_MAX_LOCAL_BYTES,
|
|
8
|
+
NOTIFICATION_AVATAR_SIZE,
|
|
9
|
+
NOTIFICATION_AVATAR_SPEC_VERSION,
|
|
10
|
+
notificationAvatarDiscHex,
|
|
11
|
+
notificationAvatarGeometry,
|
|
12
|
+
notificationAvatarSvg,
|
|
13
|
+
} from "../notification-avatar.js";
|
|
14
|
+
|
|
15
|
+
function allAttributes(tag: string, svg: string): Record<string, string>[] {
|
|
16
|
+
return [...svg.matchAll(new RegExp(`<${tag}\\s([^>]*)/>`, "g"))].map(
|
|
17
|
+
(match) => {
|
|
18
|
+
const out: Record<string, string> = {};
|
|
19
|
+
for (const [, name, value] of match[1]!.matchAll(
|
|
20
|
+
/([\w:-]+)="([^"]*)"/g,
|
|
21
|
+
)) {
|
|
22
|
+
out[name!] = value!;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function attributes(tag: string, svg: string): Record<string, string> {
|
|
30
|
+
const all = allAttributes(tag, svg);
|
|
31
|
+
expect(all.length).toBeGreaterThan(0);
|
|
32
|
+
return all[0]!;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function countOf(tag: string, svg: string): number {
|
|
36
|
+
return svg.split(`<${tag}`).length - 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PNG_BASE64 = "iVBORw0KGgo=";
|
|
40
|
+
|
|
41
|
+
describe("notificationAvatarDiscHex", () => {
|
|
42
|
+
test("mixes the accent into white", () => {
|
|
43
|
+
expect(notificationAvatarDiscHex("#E9642F")).toBe("#FCE9E2");
|
|
44
|
+
expect(notificationAvatarDiscHex("#4C9B50")).toBe("#E6F1E7");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("is case insensitive and always returns uppercase #RRGGBB", () => {
|
|
48
|
+
expect(notificationAvatarDiscHex("#e9642f")).toBe("#FCE9E2");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("keeps white white and lifts black to near white", () => {
|
|
52
|
+
expect(notificationAvatarDiscHex("#FFFFFF")).toBe("#FFFFFF");
|
|
53
|
+
expect(notificationAvatarDiscHex("#000000")).toBe("#DBDBDB");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test.each([
|
|
57
|
+
["null", null],
|
|
58
|
+
["short form", "#abc"],
|
|
59
|
+
["no hash", "e9642f"],
|
|
60
|
+
["garbage", "not a colour"],
|
|
61
|
+
["empty", ""],
|
|
62
|
+
])("falls back to the neutral disc for %s", (_label, value) => {
|
|
63
|
+
expect(notificationAvatarDiscHex(value as string | null)).toBe(
|
|
64
|
+
NOTIFICATION_AVATAR_FALLBACK_DISC_HEX,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("notificationAvatarSvg", () => {
|
|
70
|
+
test("draws one disc and one inset avatar at the default size", () => {
|
|
71
|
+
const svg = notificationAvatarSvg({
|
|
72
|
+
innerPngBase64: PNG_BASE64,
|
|
73
|
+
accentHex: "#E9642F",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(countOf("svg", svg)).toBe(1);
|
|
77
|
+
expect(countOf("image", svg)).toBe(1);
|
|
78
|
+
|
|
79
|
+
expect(svg.startsWith("<svg ")).toBe(true);
|
|
80
|
+
expect(svg).toContain(`width="256" height="256"`);
|
|
81
|
+
expect(svg).toContain(`viewBox="0 0 256 256"`);
|
|
82
|
+
|
|
83
|
+
const circle = attributes("circle", svg);
|
|
84
|
+
expect(circle).toMatchObject({ cx: "128", cy: "128", r: "128" });
|
|
85
|
+
expect(circle.fill).toBe("#FCE9E2");
|
|
86
|
+
|
|
87
|
+
const inset = NOTIFICATION_AVATAR_SIZE * NOTIFICATION_AVATAR_INSET;
|
|
88
|
+
const inner = NOTIFICATION_AVATAR_SIZE - 2 * inset;
|
|
89
|
+
const image = attributes("image", svg);
|
|
90
|
+
expect(Number(image.x)).toBeCloseTo(inset, 3);
|
|
91
|
+
expect(Number(image.y)).toBeCloseTo(inset, 3);
|
|
92
|
+
expect(Number(image.width)).toBeCloseTo(inner, 3);
|
|
93
|
+
expect(Number(image.height)).toBeCloseTo(inner, 3);
|
|
94
|
+
expect(image.href).toBe(`data:image/png;base64,${PNG_BASE64}`);
|
|
95
|
+
expect(image["xlink:href"]).toBe(image.href);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("cover-crops a non-square raster and clips it to the disc", () => {
|
|
99
|
+
// The builder never sees the source dimensions, so what makes a 1024x512
|
|
100
|
+
// upload fill the square instead of letterboxing is the pair of attributes
|
|
101
|
+
// asserted here.
|
|
102
|
+
const svg = notificationAvatarSvg({
|
|
103
|
+
innerPngBase64: PNG_BASE64,
|
|
104
|
+
accentHex: "#E9642F",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const image = attributes("image", svg);
|
|
108
|
+
expect(image.preserveAspectRatio).toBe("xMidYMid slice");
|
|
109
|
+
|
|
110
|
+
const clipRef = image["clip-path"]!;
|
|
111
|
+
const clipId = clipRef.match(/^url\(#([\w-]+)\)$/)?.[1];
|
|
112
|
+
expect(clipId).toBeTruthy();
|
|
113
|
+
expect(svg).toContain(`<clipPath id="${clipId}">`);
|
|
114
|
+
|
|
115
|
+
// The clip circle carries the disc geometry and nothing else, so the
|
|
116
|
+
// painted disc and the boundary can never drift apart.
|
|
117
|
+
const [disc, clip] = allAttributes("circle", svg);
|
|
118
|
+
expect(clip).toEqual({ cx: "128", cy: "128", r: "128" });
|
|
119
|
+
expect(disc).toMatchObject({ cx: "128", cy: "128", r: "128" });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("scales the geometry to a custom size", () => {
|
|
123
|
+
const svg = notificationAvatarSvg({
|
|
124
|
+
innerPngBase64: PNG_BASE64,
|
|
125
|
+
accentHex: "#E9642F",
|
|
126
|
+
size: 100,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(svg).toContain(`width="100" height="100"`);
|
|
130
|
+
expect(attributes("circle", svg)).toMatchObject({
|
|
131
|
+
cx: "50",
|
|
132
|
+
cy: "50",
|
|
133
|
+
r: "50",
|
|
134
|
+
});
|
|
135
|
+
expect(attributes("image", svg)).toMatchObject({
|
|
136
|
+
x: "11",
|
|
137
|
+
y: "11",
|
|
138
|
+
width: "78",
|
|
139
|
+
height: "78",
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("carries a non-PNG inner raster with its own media type", () => {
|
|
144
|
+
const svg = notificationAvatarSvg({
|
|
145
|
+
innerPngBase64: PNG_BASE64,
|
|
146
|
+
innerMediaType: "image/jpeg",
|
|
147
|
+
accentHex: "#E9642F",
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const image = attributes("image", svg);
|
|
151
|
+
expect(image.href).toBe(`data:image/jpeg;base64,${PNG_BASE64}`);
|
|
152
|
+
expect(image["xlink:href"]).toBe(image.href);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("paints the fallback disc when there is no accent", () => {
|
|
156
|
+
const svg = notificationAvatarSvg({
|
|
157
|
+
innerPngBase64: PNG_BASE64,
|
|
158
|
+
accentHex: null,
|
|
159
|
+
});
|
|
160
|
+
expect(attributes("circle", svg).fill).toBe(
|
|
161
|
+
NOTIFICATION_AVATAR_FALLBACK_DISC_HEX,
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("notificationAvatarGeometry", () => {
|
|
167
|
+
test("derives the disc, the border and the avatar edge from the size", () => {
|
|
168
|
+
expect(notificationAvatarGeometry(100)).toEqual({
|
|
169
|
+
radius: 50,
|
|
170
|
+
offset: 11,
|
|
171
|
+
inner: 78,
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("measures the default size when given none", () => {
|
|
176
|
+
expect(notificationAvatarGeometry()).toEqual(
|
|
177
|
+
notificationAvatarGeometry(NOTIFICATION_AVATAR_SIZE),
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("is the geometry the SVG is drawn with", () => {
|
|
182
|
+
const size = 100;
|
|
183
|
+
const { radius, offset, inner } = notificationAvatarGeometry(size);
|
|
184
|
+
const svg = notificationAvatarSvg({
|
|
185
|
+
innerPngBase64: PNG_BASE64,
|
|
186
|
+
accentHex: null,
|
|
187
|
+
size,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(attributes("circle", svg)).toMatchObject({
|
|
191
|
+
cx: String(radius),
|
|
192
|
+
cy: String(radius),
|
|
193
|
+
r: String(radius),
|
|
194
|
+
});
|
|
195
|
+
expect(attributes("image", svg)).toMatchObject({
|
|
196
|
+
x: String(offset),
|
|
197
|
+
y: String(offset),
|
|
198
|
+
width: String(inner),
|
|
199
|
+
height: String(inner),
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("the transport contract", () => {
|
|
205
|
+
test("caps a notification PNG at 128 KB for the platform sync", () => {
|
|
206
|
+
expect(NOTIFICATION_AVATAR_MAX_BYTES).toBe(128 * 1024);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("gives the local desktop path a looser cap of its own", () => {
|
|
210
|
+
expect(NOTIFICATION_AVATAR_MAX_LOCAL_BYTES).toBe(512 * 1024);
|
|
211
|
+
expect(NOTIFICATION_AVATAR_MAX_LOCAL_BYTES).toBeGreaterThan(
|
|
212
|
+
NOTIFICATION_AVATAR_MAX_BYTES,
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("stamps the drawing with a spec version", () => {
|
|
217
|
+
expect(Number.isInteger(NOTIFICATION_AVATAR_SPEC_VERSION)).toBe(true);
|
|
218
|
+
expect(NOTIFICATION_AVATAR_SPEC_VERSION).toBeGreaterThan(0);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notification avatar: the assistant's avatar centered on an accent-tinted
|
|
3
|
+
* disc, the icon a native notification shows as the sender.
|
|
4
|
+
*
|
|
5
|
+
* The disc is inscribed in the square, so the four corners stay transparent.
|
|
6
|
+
* That is deliberate: iOS, Android, and the Windows toast logo slot all
|
|
7
|
+
* circle-crop what they are handed, and a square of colour behind the disc
|
|
8
|
+
* would only show through as a ring on any surface that does not crop. The
|
|
9
|
+
* disc itself is fully opaque, and so is everything inside it.
|
|
10
|
+
*
|
|
11
|
+
* Every platform draws the same picture with a different rasterizer (resvg in
|
|
12
|
+
* the daemon, a canvas in the desktop renderer), so the geometry and the disc
|
|
13
|
+
* colour live here once. Pure arithmetic and string building, no decoder and
|
|
14
|
+
* no node builtins, so a browser can call it too.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { isAvatarAccentHex } from "./accent.js";
|
|
18
|
+
|
|
19
|
+
/** Output edge in pixels: large enough for an iOS notification thumbnail. */
|
|
20
|
+
export const NOTIFICATION_AVATAR_SIZE = 256;
|
|
21
|
+
|
|
22
|
+
/** Free disc on each side, as a fraction of the edge; the avatar gets the rest. */
|
|
23
|
+
export const NOTIFICATION_AVATAR_INSET = 0.11;
|
|
24
|
+
|
|
25
|
+
/** The disc when the assistant has no accent. */
|
|
26
|
+
export const NOTIFICATION_AVATAR_FALLBACK_DISC_HEX = "#ECEFEA";
|
|
27
|
+
|
|
28
|
+
/** How much of the accent survives the mix into white. */
|
|
29
|
+
const ACCENT_MIX = 0.14;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Largest notification PNG the platform sync ships, in bytes. It bounds the
|
|
33
|
+
* `notification_avatar_base64` field of the PATCH that uploads the disc, which
|
|
34
|
+
* is what APNs and FCM later hand their clients a URL and a hash for. A
|
|
35
|
+
* photographic avatar is quantised (or dropped) to fit.
|
|
36
|
+
*/
|
|
37
|
+
export const NOTIFICATION_AVATAR_MAX_BYTES = 128 * 1024;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Largest notification PNG the local desktop path carries, in bytes. The
|
|
41
|
+
* renderer composites the disc, hands it to the Electron host over IPC and the
|
|
42
|
+
* host caches it on disk, so nothing crosses a push transport and a
|
|
43
|
+
* photographic avatar that renders past the sync cap still shows.
|
|
44
|
+
*/
|
|
45
|
+
export const NOTIFICATION_AVATAR_MAX_LOCAL_BYTES = 512 * 1024;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Bumped whenever the drawing above changes, so a sync keyed on it re-uploads
|
|
49
|
+
* a disc rendered by an older spec even when the avatar itself is unchanged.
|
|
50
|
+
*/
|
|
51
|
+
export const NOTIFICATION_AVATAR_SPEC_VERSION = 1;
|
|
52
|
+
|
|
53
|
+
/** The id the inner raster's clip path is referenced by inside the document. */
|
|
54
|
+
const CLIP_ID = "notification-avatar-disc";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The disc fill for an accent: the accent mixed into white, so the avatar reads
|
|
58
|
+
* against it at notification size. Uppercase `#RRGGBB`; the neutral fallback
|
|
59
|
+
* when there is no accent or the value is not a hex.
|
|
60
|
+
*/
|
|
61
|
+
export function notificationAvatarDiscHex(accentHex: string | null): string {
|
|
62
|
+
if (!isAvatarAccentHex(accentHex)) {
|
|
63
|
+
return NOTIFICATION_AVATAR_FALLBACK_DISC_HEX;
|
|
64
|
+
}
|
|
65
|
+
const rgb = parseInt(accentHex.slice(1), 16);
|
|
66
|
+
const mixed = (shift: number) => {
|
|
67
|
+
const channel = (rgb >> shift) & 0xff;
|
|
68
|
+
return Math.round(255 + (channel - 255) * ACCENT_MIX)
|
|
69
|
+
.toString(16)
|
|
70
|
+
.padStart(2, "0");
|
|
71
|
+
};
|
|
72
|
+
return `#${mixed(16)}${mixed(8)}${mixed(0)}`.toUpperCase();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Raster formats an `<image>` href carries here; what resvg and a canvas both decode. */
|
|
76
|
+
export type NotificationAvatarMediaType =
|
|
77
|
+
| "image/png"
|
|
78
|
+
| "image/jpeg"
|
|
79
|
+
| "image/gif";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The three measurements the drawing derives from the edge: the disc's radius,
|
|
83
|
+
* which is also its centre; the free border around the avatar; and the
|
|
84
|
+
* avatar's own edge. Every rasterizer reads them from here, so the daemon's
|
|
85
|
+
* SVG and the desktop canvas cannot drift.
|
|
86
|
+
*/
|
|
87
|
+
export function notificationAvatarGeometry(
|
|
88
|
+
size: number = NOTIFICATION_AVATAR_SIZE,
|
|
89
|
+
): { radius: number; offset: number; inner: number } {
|
|
90
|
+
const offset = size * NOTIFICATION_AVATAR_INSET;
|
|
91
|
+
return { radius: size / 2, offset, inner: size - 2 * offset };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface NotificationAvatarSvgOptions {
|
|
95
|
+
/** The avatar raster to draw inside the disc, base64 with no data prefix. */
|
|
96
|
+
innerPngBase64: string;
|
|
97
|
+
/** What `innerPngBase64` holds; PNG unless an upload arrived as a JPEG or a GIF. */
|
|
98
|
+
innerMediaType?: NotificationAvatarMediaType;
|
|
99
|
+
accentHex: string | null;
|
|
100
|
+
size?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Trims the float noise a fractional inset leaves in the coordinates. */
|
|
104
|
+
function px(value: number): string {
|
|
105
|
+
return String(Number(value.toFixed(3)));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The notification avatar as an SVG document: a filled disc with the avatar
|
|
110
|
+
* drawn inset into it.
|
|
111
|
+
*
|
|
112
|
+
* The inner raster is cover-cropped (`xMidYMid slice`), matching what the web
|
|
113
|
+
* canvas does, so a portrait or landscape upload fills the square instead of
|
|
114
|
+
* being letterboxed onto the disc. It is then clipped to the disc, so the disc
|
|
115
|
+
* edge is the hard boundary no matter how far a cover-crop overflows.
|
|
116
|
+
*
|
|
117
|
+
* The `<image href="data:...">` shape is the one `downscaleRaster()` in
|
|
118
|
+
* `assistant/src/platform/sync-avatar.ts` already feeds resvg, so the daemon's
|
|
119
|
+
* rasterizer is known to render it.
|
|
120
|
+
*/
|
|
121
|
+
export function notificationAvatarSvg({
|
|
122
|
+
innerPngBase64,
|
|
123
|
+
innerMediaType = "image/png",
|
|
124
|
+
accentHex,
|
|
125
|
+
size = NOTIFICATION_AVATAR_SIZE,
|
|
126
|
+
}: NotificationAvatarSvgOptions): string {
|
|
127
|
+
const { radius, offset, inner } = notificationAvatarGeometry(size);
|
|
128
|
+
const href = `data:${innerMediaType};base64,${innerPngBase64}`;
|
|
129
|
+
const disc = `cx="${px(radius)}" cy="${px(radius)}" r="${px(radius)}"`;
|
|
130
|
+
return (
|
|
131
|
+
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" ` +
|
|
132
|
+
`width="${px(size)}" height="${px(size)}" viewBox="0 0 ${px(size)} ${px(size)}">` +
|
|
133
|
+
`<circle ${disc} fill="${notificationAvatarDiscHex(accentHex)}"/>` +
|
|
134
|
+
`<defs><clipPath id="${CLIP_ID}"><circle ${disc}/></clipPath></defs>` +
|
|
135
|
+
`<image x="${px(offset)}" y="${px(offset)}" width="${px(inner)}" height="${px(inner)}" ` +
|
|
136
|
+
`preserveAspectRatio="xMidYMid slice" clip-path="url(#${CLIP_ID})" ` +
|
|
137
|
+
`href="${href}" xlink:href="${href}"/></svg>`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
NOTIFICATION_AVATAR_FALLBACK_DISC_HEX,
|
|
5
|
+
NOTIFICATION_AVATAR_INSET,
|
|
6
|
+
NOTIFICATION_AVATAR_MAX_BYTES,
|
|
7
|
+
NOTIFICATION_AVATAR_MAX_LOCAL_BYTES,
|
|
8
|
+
NOTIFICATION_AVATAR_SIZE,
|
|
9
|
+
NOTIFICATION_AVATAR_SPEC_VERSION,
|
|
10
|
+
notificationAvatarDiscHex,
|
|
11
|
+
notificationAvatarGeometry,
|
|
12
|
+
notificationAvatarSvg,
|
|
13
|
+
} from "../notification-avatar.js";
|
|
14
|
+
|
|
15
|
+
function allAttributes(tag: string, svg: string): Record<string, string>[] {
|
|
16
|
+
return [...svg.matchAll(new RegExp(`<${tag}\\s([^>]*)/>`, "g"))].map(
|
|
17
|
+
(match) => {
|
|
18
|
+
const out: Record<string, string> = {};
|
|
19
|
+
for (const [, name, value] of match[1]!.matchAll(
|
|
20
|
+
/([\w:-]+)="([^"]*)"/g,
|
|
21
|
+
)) {
|
|
22
|
+
out[name!] = value!;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function attributes(tag: string, svg: string): Record<string, string> {
|
|
30
|
+
const all = allAttributes(tag, svg);
|
|
31
|
+
expect(all.length).toBeGreaterThan(0);
|
|
32
|
+
return all[0]!;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function countOf(tag: string, svg: string): number {
|
|
36
|
+
return svg.split(`<${tag}`).length - 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PNG_BASE64 = "iVBORw0KGgo=";
|
|
40
|
+
|
|
41
|
+
describe("notificationAvatarDiscHex", () => {
|
|
42
|
+
test("mixes the accent into white", () => {
|
|
43
|
+
expect(notificationAvatarDiscHex("#E9642F")).toBe("#FCE9E2");
|
|
44
|
+
expect(notificationAvatarDiscHex("#4C9B50")).toBe("#E6F1E7");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("is case insensitive and always returns uppercase #RRGGBB", () => {
|
|
48
|
+
expect(notificationAvatarDiscHex("#e9642f")).toBe("#FCE9E2");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("keeps white white and lifts black to near white", () => {
|
|
52
|
+
expect(notificationAvatarDiscHex("#FFFFFF")).toBe("#FFFFFF");
|
|
53
|
+
expect(notificationAvatarDiscHex("#000000")).toBe("#DBDBDB");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test.each([
|
|
57
|
+
["null", null],
|
|
58
|
+
["short form", "#abc"],
|
|
59
|
+
["no hash", "e9642f"],
|
|
60
|
+
["garbage", "not a colour"],
|
|
61
|
+
["empty", ""],
|
|
62
|
+
])("falls back to the neutral disc for %s", (_label, value) => {
|
|
63
|
+
expect(notificationAvatarDiscHex(value as string | null)).toBe(
|
|
64
|
+
NOTIFICATION_AVATAR_FALLBACK_DISC_HEX,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("notificationAvatarSvg", () => {
|
|
70
|
+
test("draws one disc and one inset avatar at the default size", () => {
|
|
71
|
+
const svg = notificationAvatarSvg({
|
|
72
|
+
innerPngBase64: PNG_BASE64,
|
|
73
|
+
accentHex: "#E9642F",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(countOf("svg", svg)).toBe(1);
|
|
77
|
+
expect(countOf("image", svg)).toBe(1);
|
|
78
|
+
|
|
79
|
+
expect(svg.startsWith("<svg ")).toBe(true);
|
|
80
|
+
expect(svg).toContain(`width="256" height="256"`);
|
|
81
|
+
expect(svg).toContain(`viewBox="0 0 256 256"`);
|
|
82
|
+
|
|
83
|
+
const circle = attributes("circle", svg);
|
|
84
|
+
expect(circle).toMatchObject({ cx: "128", cy: "128", r: "128" });
|
|
85
|
+
expect(circle.fill).toBe("#FCE9E2");
|
|
86
|
+
|
|
87
|
+
const inset = NOTIFICATION_AVATAR_SIZE * NOTIFICATION_AVATAR_INSET;
|
|
88
|
+
const inner = NOTIFICATION_AVATAR_SIZE - 2 * inset;
|
|
89
|
+
const image = attributes("image", svg);
|
|
90
|
+
expect(Number(image.x)).toBeCloseTo(inset, 3);
|
|
91
|
+
expect(Number(image.y)).toBeCloseTo(inset, 3);
|
|
92
|
+
expect(Number(image.width)).toBeCloseTo(inner, 3);
|
|
93
|
+
expect(Number(image.height)).toBeCloseTo(inner, 3);
|
|
94
|
+
expect(image.href).toBe(`data:image/png;base64,${PNG_BASE64}`);
|
|
95
|
+
expect(image["xlink:href"]).toBe(image.href);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("cover-crops a non-square raster and clips it to the disc", () => {
|
|
99
|
+
// The builder never sees the source dimensions, so what makes a 1024x512
|
|
100
|
+
// upload fill the square instead of letterboxing is the pair of attributes
|
|
101
|
+
// asserted here.
|
|
102
|
+
const svg = notificationAvatarSvg({
|
|
103
|
+
innerPngBase64: PNG_BASE64,
|
|
104
|
+
accentHex: "#E9642F",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const image = attributes("image", svg);
|
|
108
|
+
expect(image.preserveAspectRatio).toBe("xMidYMid slice");
|
|
109
|
+
|
|
110
|
+
const clipRef = image["clip-path"]!;
|
|
111
|
+
const clipId = clipRef.match(/^url\(#([\w-]+)\)$/)?.[1];
|
|
112
|
+
expect(clipId).toBeTruthy();
|
|
113
|
+
expect(svg).toContain(`<clipPath id="${clipId}">`);
|
|
114
|
+
|
|
115
|
+
// The clip circle carries the disc geometry and nothing else, so the
|
|
116
|
+
// painted disc and the boundary can never drift apart.
|
|
117
|
+
const [disc, clip] = allAttributes("circle", svg);
|
|
118
|
+
expect(clip).toEqual({ cx: "128", cy: "128", r: "128" });
|
|
119
|
+
expect(disc).toMatchObject({ cx: "128", cy: "128", r: "128" });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("scales the geometry to a custom size", () => {
|
|
123
|
+
const svg = notificationAvatarSvg({
|
|
124
|
+
innerPngBase64: PNG_BASE64,
|
|
125
|
+
accentHex: "#E9642F",
|
|
126
|
+
size: 100,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(svg).toContain(`width="100" height="100"`);
|
|
130
|
+
expect(attributes("circle", svg)).toMatchObject({
|
|
131
|
+
cx: "50",
|
|
132
|
+
cy: "50",
|
|
133
|
+
r: "50",
|
|
134
|
+
});
|
|
135
|
+
expect(attributes("image", svg)).toMatchObject({
|
|
136
|
+
x: "11",
|
|
137
|
+
y: "11",
|
|
138
|
+
width: "78",
|
|
139
|
+
height: "78",
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("carries a non-PNG inner raster with its own media type", () => {
|
|
144
|
+
const svg = notificationAvatarSvg({
|
|
145
|
+
innerPngBase64: PNG_BASE64,
|
|
146
|
+
innerMediaType: "image/jpeg",
|
|
147
|
+
accentHex: "#E9642F",
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const image = attributes("image", svg);
|
|
151
|
+
expect(image.href).toBe(`data:image/jpeg;base64,${PNG_BASE64}`);
|
|
152
|
+
expect(image["xlink:href"]).toBe(image.href);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("paints the fallback disc when there is no accent", () => {
|
|
156
|
+
const svg = notificationAvatarSvg({
|
|
157
|
+
innerPngBase64: PNG_BASE64,
|
|
158
|
+
accentHex: null,
|
|
159
|
+
});
|
|
160
|
+
expect(attributes("circle", svg).fill).toBe(
|
|
161
|
+
NOTIFICATION_AVATAR_FALLBACK_DISC_HEX,
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("notificationAvatarGeometry", () => {
|
|
167
|
+
test("derives the disc, the border and the avatar edge from the size", () => {
|
|
168
|
+
expect(notificationAvatarGeometry(100)).toEqual({
|
|
169
|
+
radius: 50,
|
|
170
|
+
offset: 11,
|
|
171
|
+
inner: 78,
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("measures the default size when given none", () => {
|
|
176
|
+
expect(notificationAvatarGeometry()).toEqual(
|
|
177
|
+
notificationAvatarGeometry(NOTIFICATION_AVATAR_SIZE),
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("is the geometry the SVG is drawn with", () => {
|
|
182
|
+
const size = 100;
|
|
183
|
+
const { radius, offset, inner } = notificationAvatarGeometry(size);
|
|
184
|
+
const svg = notificationAvatarSvg({
|
|
185
|
+
innerPngBase64: PNG_BASE64,
|
|
186
|
+
accentHex: null,
|
|
187
|
+
size,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(attributes("circle", svg)).toMatchObject({
|
|
191
|
+
cx: String(radius),
|
|
192
|
+
cy: String(radius),
|
|
193
|
+
r: String(radius),
|
|
194
|
+
});
|
|
195
|
+
expect(attributes("image", svg)).toMatchObject({
|
|
196
|
+
x: String(offset),
|
|
197
|
+
y: String(offset),
|
|
198
|
+
width: String(inner),
|
|
199
|
+
height: String(inner),
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("the transport contract", () => {
|
|
205
|
+
test("caps a notification PNG at 128 KB for the platform sync", () => {
|
|
206
|
+
expect(NOTIFICATION_AVATAR_MAX_BYTES).toBe(128 * 1024);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("gives the local desktop path a looser cap of its own", () => {
|
|
210
|
+
expect(NOTIFICATION_AVATAR_MAX_LOCAL_BYTES).toBe(512 * 1024);
|
|
211
|
+
expect(NOTIFICATION_AVATAR_MAX_LOCAL_BYTES).toBeGreaterThan(
|
|
212
|
+
NOTIFICATION_AVATAR_MAX_BYTES,
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("stamps the drawing with a spec version", () => {
|
|
217
|
+
expect(Number.isInteger(NOTIFICATION_AVATAR_SPEC_VERSION)).toBe(true);
|
|
218
|
+
expect(NOTIFICATION_AVATAR_SPEC_VERSION).toBeGreaterThan(0);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notification avatar: the assistant's avatar centered on an accent-tinted
|
|
3
|
+
* disc, the icon a native notification shows as the sender.
|
|
4
|
+
*
|
|
5
|
+
* The disc is inscribed in the square, so the four corners stay transparent.
|
|
6
|
+
* That is deliberate: iOS, Android, and the Windows toast logo slot all
|
|
7
|
+
* circle-crop what they are handed, and a square of colour behind the disc
|
|
8
|
+
* would only show through as a ring on any surface that does not crop. The
|
|
9
|
+
* disc itself is fully opaque, and so is everything inside it.
|
|
10
|
+
*
|
|
11
|
+
* Every platform draws the same picture with a different rasterizer (resvg in
|
|
12
|
+
* the daemon, a canvas in the desktop renderer), so the geometry and the disc
|
|
13
|
+
* colour live here once. Pure arithmetic and string building, no decoder and
|
|
14
|
+
* no node builtins, so a browser can call it too.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { isAvatarAccentHex } from "./accent.js";
|
|
18
|
+
|
|
19
|
+
/** Output edge in pixels: large enough for an iOS notification thumbnail. */
|
|
20
|
+
export const NOTIFICATION_AVATAR_SIZE = 256;
|
|
21
|
+
|
|
22
|
+
/** Free disc on each side, as a fraction of the edge; the avatar gets the rest. */
|
|
23
|
+
export const NOTIFICATION_AVATAR_INSET = 0.11;
|
|
24
|
+
|
|
25
|
+
/** The disc when the assistant has no accent. */
|
|
26
|
+
export const NOTIFICATION_AVATAR_FALLBACK_DISC_HEX = "#ECEFEA";
|
|
27
|
+
|
|
28
|
+
/** How much of the accent survives the mix into white. */
|
|
29
|
+
const ACCENT_MIX = 0.14;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Largest notification PNG the platform sync ships, in bytes. It bounds the
|
|
33
|
+
* `notification_avatar_base64` field of the PATCH that uploads the disc, which
|
|
34
|
+
* is what APNs and FCM later hand their clients a URL and a hash for. A
|
|
35
|
+
* photographic avatar is quantised (or dropped) to fit.
|
|
36
|
+
*/
|
|
37
|
+
export const NOTIFICATION_AVATAR_MAX_BYTES = 128 * 1024;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Largest notification PNG the local desktop path carries, in bytes. The
|
|
41
|
+
* renderer composites the disc, hands it to the Electron host over IPC and the
|
|
42
|
+
* host caches it on disk, so nothing crosses a push transport and a
|
|
43
|
+
* photographic avatar that renders past the sync cap still shows.
|
|
44
|
+
*/
|
|
45
|
+
export const NOTIFICATION_AVATAR_MAX_LOCAL_BYTES = 512 * 1024;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Bumped whenever the drawing above changes, so a sync keyed on it re-uploads
|
|
49
|
+
* a disc rendered by an older spec even when the avatar itself is unchanged.
|
|
50
|
+
*/
|
|
51
|
+
export const NOTIFICATION_AVATAR_SPEC_VERSION = 1;
|
|
52
|
+
|
|
53
|
+
/** The id the inner raster's clip path is referenced by inside the document. */
|
|
54
|
+
const CLIP_ID = "notification-avatar-disc";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The disc fill for an accent: the accent mixed into white, so the avatar reads
|
|
58
|
+
* against it at notification size. Uppercase `#RRGGBB`; the neutral fallback
|
|
59
|
+
* when there is no accent or the value is not a hex.
|
|
60
|
+
*/
|
|
61
|
+
export function notificationAvatarDiscHex(accentHex: string | null): string {
|
|
62
|
+
if (!isAvatarAccentHex(accentHex)) {
|
|
63
|
+
return NOTIFICATION_AVATAR_FALLBACK_DISC_HEX;
|
|
64
|
+
}
|
|
65
|
+
const rgb = parseInt(accentHex.slice(1), 16);
|
|
66
|
+
const mixed = (shift: number) => {
|
|
67
|
+
const channel = (rgb >> shift) & 0xff;
|
|
68
|
+
return Math.round(255 + (channel - 255) * ACCENT_MIX)
|
|
69
|
+
.toString(16)
|
|
70
|
+
.padStart(2, "0");
|
|
71
|
+
};
|
|
72
|
+
return `#${mixed(16)}${mixed(8)}${mixed(0)}`.toUpperCase();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Raster formats an `<image>` href carries here; what resvg and a canvas both decode. */
|
|
76
|
+
export type NotificationAvatarMediaType =
|
|
77
|
+
| "image/png"
|
|
78
|
+
| "image/jpeg"
|
|
79
|
+
| "image/gif";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The three measurements the drawing derives from the edge: the disc's radius,
|
|
83
|
+
* which is also its centre; the free border around the avatar; and the
|
|
84
|
+
* avatar's own edge. Every rasterizer reads them from here, so the daemon's
|
|
85
|
+
* SVG and the desktop canvas cannot drift.
|
|
86
|
+
*/
|
|
87
|
+
export function notificationAvatarGeometry(
|
|
88
|
+
size: number = NOTIFICATION_AVATAR_SIZE,
|
|
89
|
+
): { radius: number; offset: number; inner: number } {
|
|
90
|
+
const offset = size * NOTIFICATION_AVATAR_INSET;
|
|
91
|
+
return { radius: size / 2, offset, inner: size - 2 * offset };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface NotificationAvatarSvgOptions {
|
|
95
|
+
/** The avatar raster to draw inside the disc, base64 with no data prefix. */
|
|
96
|
+
innerPngBase64: string;
|
|
97
|
+
/** What `innerPngBase64` holds; PNG unless an upload arrived as a JPEG or a GIF. */
|
|
98
|
+
innerMediaType?: NotificationAvatarMediaType;
|
|
99
|
+
accentHex: string | null;
|
|
100
|
+
size?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Trims the float noise a fractional inset leaves in the coordinates. */
|
|
104
|
+
function px(value: number): string {
|
|
105
|
+
return String(Number(value.toFixed(3)));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The notification avatar as an SVG document: a filled disc with the avatar
|
|
110
|
+
* drawn inset into it.
|
|
111
|
+
*
|
|
112
|
+
* The inner raster is cover-cropped (`xMidYMid slice`), matching what the web
|
|
113
|
+
* canvas does, so a portrait or landscape upload fills the square instead of
|
|
114
|
+
* being letterboxed onto the disc. It is then clipped to the disc, so the disc
|
|
115
|
+
* edge is the hard boundary no matter how far a cover-crop overflows.
|
|
116
|
+
*
|
|
117
|
+
* The `<image href="data:...">` shape is the one `downscaleRaster()` in
|
|
118
|
+
* `assistant/src/platform/sync-avatar.ts` already feeds resvg, so the daemon's
|
|
119
|
+
* rasterizer is known to render it.
|
|
120
|
+
*/
|
|
121
|
+
export function notificationAvatarSvg({
|
|
122
|
+
innerPngBase64,
|
|
123
|
+
innerMediaType = "image/png",
|
|
124
|
+
accentHex,
|
|
125
|
+
size = NOTIFICATION_AVATAR_SIZE,
|
|
126
|
+
}: NotificationAvatarSvgOptions): string {
|
|
127
|
+
const { radius, offset, inner } = notificationAvatarGeometry(size);
|
|
128
|
+
const href = `data:${innerMediaType};base64,${innerPngBase64}`;
|
|
129
|
+
const disc = `cx="${px(radius)}" cy="${px(radius)}" r="${px(radius)}"`;
|
|
130
|
+
return (
|
|
131
|
+
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" ` +
|
|
132
|
+
`width="${px(size)}" height="${px(size)}" viewBox="0 0 ${px(size)} ${px(size)}">` +
|
|
133
|
+
`<circle ${disc} fill="${notificationAvatarDiscHex(accentHex)}"/>` +
|
|
134
|
+
`<defs><clipPath id="${CLIP_ID}"><circle ${disc}/></clipPath></defs>` +
|
|
135
|
+
`<image x="${px(offset)}" y="${px(offset)}" width="${px(inner)}" height="${px(inner)}" ` +
|
|
136
|
+
`preserveAspectRatio="xMidYMid slice" clip-path="url(#${CLIP_ID})" ` +
|
|
137
|
+
`href="${href}" xlink:href="${href}"/></svg>`
|
|
138
|
+
);
|
|
139
|
+
}
|
package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/package.json
CHANGED
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"./error": "./src/error.ts",
|
|
27
27
|
"./reactions": "./src/reactions.ts",
|
|
28
28
|
"./guardian-requests": "./src/guardian-requests.ts",
|
|
29
|
-
"./platform-credential": "./src/platform-credential.ts"
|
|
29
|
+
"./platform-credential": "./src/platform-credential.ts",
|
|
30
|
+
"./plan-credit": "./src/plan-credit.ts"
|
|
30
31
|
},
|
|
31
32
|
"scripts": {
|
|
32
33
|
"typecheck": "bunx tsc --noEmit",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
extraCreditUsd,
|
|
5
|
+
planCreditUsedFraction,
|
|
6
|
+
usageGrantRatio,
|
|
7
|
+
} from "../plan-credit.js";
|
|
8
|
+
|
|
9
|
+
describe("usageGrantRatio", () => {
|
|
10
|
+
test("reads the used share and clamps to 0..1", () => {
|
|
11
|
+
expect(usageGrantRatio(20, 9.1)).toBeCloseTo(0.545);
|
|
12
|
+
expect(usageGrantRatio(20, 0)).toBe(1);
|
|
13
|
+
expect(usageGrantRatio(20, 25)).toBe(0);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("has no reading without both figures or with nothing granted", () => {
|
|
17
|
+
expect(usageGrantRatio(null, 5)).toBeNull();
|
|
18
|
+
expect(usageGrantRatio(20, null)).toBeNull();
|
|
19
|
+
expect(usageGrantRatio(0, 0)).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("planCreditUsedFraction", () => {
|
|
24
|
+
test("prefers the ratio whatever the plan", () => {
|
|
25
|
+
expect(planCreditUsedFraction(20, 9.1, "base")).toBeCloseTo(0.545);
|
|
26
|
+
expect(planCreditUsedFraction(20, 9.1, null)).toBeCloseTo(0.545);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("treats a Pro plan with nothing granted as fully spent", () => {
|
|
30
|
+
expect(planCreditUsedFraction(0, 0, "pro")).toBe(1);
|
|
31
|
+
expect(planCreditUsedFraction(0, null, "pro")).toBe(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("gives a base or unknown plan with nothing granted no reading", () => {
|
|
35
|
+
expect(planCreditUsedFraction(0, 0, "base")).toBeNull();
|
|
36
|
+
expect(planCreditUsedFraction(0, 0, null)).toBeNull();
|
|
37
|
+
expect(planCreditUsedFraction(null, null, "pro")).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("extraCreditUsd", () => {
|
|
42
|
+
test("nets unused grant credit out of the balance to the cent", () => {
|
|
43
|
+
expect(extraCreditUsd(42.17, 9.1)).toBe(33.07);
|
|
44
|
+
expect(extraCreditUsd(34.65, 9.1)).toBe(25.55);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("never goes below zero", () => {
|
|
48
|
+
expect(extraCreditUsd(5, 9.1)).toBe(0);
|
|
49
|
+
expect(extraCreditUsd(-2.5, 0)).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ export * from "./rpc.js";
|
|
|
28
28
|
export * from "./trust-rules.js";
|
|
29
29
|
export * from "./ingress.js";
|
|
30
30
|
export * from "./no-response.js";
|
|
31
|
+
export * from "./plan-credit.js";
|
|
31
32
|
export * from "./platform-credential.js";
|
|
32
33
|
export * from "./remote-web-pairing.js";
|
|
33
34
|
export * from "./twilio-ingress.js";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan-credit derivations shared by the assistant runtime's `platform credits`
|
|
3
|
+
* route and the web client's usage meter, so both read the billing summary's
|
|
4
|
+
* grant figures the same way and can never quote different readings.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type BillingPlanId = "base" | "pro";
|
|
8
|
+
|
|
9
|
+
function clamp01(value: number): number {
|
|
10
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
return value > 1 ? 1 : value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How much of the usage credit an account was granted it has already used:
|
|
18
|
+
* the granted total less what is still unused, over that total. The initial
|
|
19
|
+
* $5 grant burns 0 to 100% as it is spent, and a further grant grows the total
|
|
20
|
+
* so the bar drops back. Null when nothing was ever granted, or when the
|
|
21
|
+
* platform reports neither figure, which has no honest reading rather than a
|
|
22
|
+
* full or empty bar.
|
|
23
|
+
*/
|
|
24
|
+
export function usageGrantRatio(
|
|
25
|
+
totalUsd: number | null,
|
|
26
|
+
availableUsd: number | null,
|
|
27
|
+
): number | null {
|
|
28
|
+
if (totalUsd == null || availableUsd == null || totalUsd <= 0) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return clamp01((totalUsd - availableUsd) / totalUsd);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The plan-credit reading with the plan's fallback applied. The grant figures
|
|
36
|
+
* count only unexpired grants, so a Pro subscription whose grants total
|
|
37
|
+
* nothing has used or expired everything it was granted (a full bar, with
|
|
38
|
+
* nothing scheduled to refill it), while a base plan in that position was
|
|
39
|
+
* never granted anything (no reading). An unknown plan gets the ratio when
|
|
40
|
+
* the figures allow one and no fallback otherwise.
|
|
41
|
+
*/
|
|
42
|
+
export function planCreditUsedFraction(
|
|
43
|
+
totalUsd: number | null,
|
|
44
|
+
availableUsd: number | null,
|
|
45
|
+
planId: BillingPlanId | null,
|
|
46
|
+
): number | null {
|
|
47
|
+
const ratio = usageGrantRatio(totalUsd, availableUsd);
|
|
48
|
+
if (ratio != null) {
|
|
49
|
+
return ratio;
|
|
50
|
+
}
|
|
51
|
+
if (planId === "pro" && totalUsd != null && totalUsd <= 0) {
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Credit bought or earned on top of the plan-included grants: the balance less
|
|
59
|
+
* whatever is still unused on those grants, never below zero, to the cent.
|
|
60
|
+
*/
|
|
61
|
+
export function extraCreditUsd(
|
|
62
|
+
balanceUsd: number,
|
|
63
|
+
availableUsd: number,
|
|
64
|
+
): number {
|
|
65
|
+
return Math.round(Math.max(0, balanceUsd - availableUsd) * 100) / 100;
|
|
66
|
+
}
|
package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/reactions.ts
CHANGED
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
* is spelled. Modelled on Zulip's `reaction_type`, the one surveyed system
|
|
8
8
|
* that separates the namespace from the name.
|
|
9
9
|
*
|
|
10
|
-
* `shortcode` is a name in a channel's own namespace
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* `shortcode` is a name in a channel's own namespace that the channel's
|
|
11
|
+
* adapter could not place among the standard emoji. Slack sends `+1` and
|
|
12
|
+
* `blob_wave` alike, as names; its normalizer resolves the standard ones to
|
|
13
|
+
* `unicode` from Slack's own list, and what remains is a workspace upload
|
|
14
|
+
* only the workspace can render. It is a distinct kind from `unicode`, not
|
|
15
|
+
* a stand-in for an unknown one, and nothing past an adapter needs a
|
|
16
|
+
* channel's naming to read it.
|
|
15
17
|
*/
|
|
16
18
|
import { z } from "zod";
|
|
17
19
|
|
|
@@ -39,6 +41,12 @@ export const ReactionEmojiFieldsSchema = z.object({
|
|
|
39
41
|
});
|
|
40
42
|
export type ReactionEmojiFields = z.infer<typeof ReactionEmojiFieldsSchema>;
|
|
41
43
|
|
|
44
|
+
/** The typed fields with the kind and name known. */
|
|
45
|
+
export type ReactionEmojiIdentity = ReactionEmojiFields & {
|
|
46
|
+
emojiKind: ReactionEmojiKind;
|
|
47
|
+
emojiName: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
42
50
|
/**
|
|
43
51
|
* The typed emoji fields a source actually carries, with undefined ones
|
|
44
52
|
* omitted: an absent key and a present-but-undefined one serialize alike, but the
|
|
@@ -58,3 +66,75 @@ export function pickReactionEmojiFields(
|
|
|
58
66
|
: {}),
|
|
59
67
|
};
|
|
60
68
|
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse Discord's custom-emoji mention form. The form travels the wire as a
|
|
72
|
+
* reaction's spelling, so more than one package reads it: the daemon rebuilds
|
|
73
|
+
* a REST path from it when the assistant reacts, and a row carrying only the
|
|
74
|
+
* spelling recovers its kind from it. One parser, so the two cannot disagree
|
|
75
|
+
* about what counts as one.
|
|
76
|
+
*
|
|
77
|
+
* `animated` reports whether the spelling carries the `a` marker. Discord's
|
|
78
|
+
* normalizer never writes that marker, so it is read only from spellings that
|
|
79
|
+
* arrive from elsewhere: a row written by another producer, or a value the
|
|
80
|
+
* model hands back as it received it.
|
|
81
|
+
*/
|
|
82
|
+
export function parseDiscordEmojiMention(
|
|
83
|
+
emoji: string,
|
|
84
|
+
): { name: string; id: string; animated: boolean } | null {
|
|
85
|
+
const match = /^<(a?):([^:>]+):(\d+)>$/.exec(emoji);
|
|
86
|
+
return match
|
|
87
|
+
? { name: match[2]!, id: match[3]!, animated: match[1] === "a" }
|
|
88
|
+
: null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Recover an emoji's kind from its spelling alone. This is the one inference
|
|
93
|
+
* the design permits, reserved for a value that carries the string and no
|
|
94
|
+
* typed fields: a persisted row or a replayed retry payload. A value that
|
|
95
|
+
* declares its kind never reaches this; `reactionEmojiIdentity` makes that
|
|
96
|
+
* choice for every reader.
|
|
97
|
+
*
|
|
98
|
+
* A mention form is unambiguous. Past that the two remaining kinds are told
|
|
99
|
+
* apart by whether the string is a name at all: a channel's shortcode is
|
|
100
|
+
* ASCII word characters, optionally carrying Slack's `::skin-tone-N`
|
|
101
|
+
* suffix, and anything else is the character itself.
|
|
102
|
+
*/
|
|
103
|
+
export function classifyReactionEmojiSpelling(
|
|
104
|
+
emoji: string,
|
|
105
|
+
): ReactionEmojiIdentity {
|
|
106
|
+
const custom = parseDiscordEmojiMention(emoji);
|
|
107
|
+
if (custom) {
|
|
108
|
+
// The plain `<:name:id>` form says nothing about animation: the
|
|
109
|
+
// normalizer spells every custom emoji that way and reports animation
|
|
110
|
+
// in the typed field instead, so a spelling without the `a` marker is
|
|
111
|
+
// "unrecorded", not "not animated". Only the `<a:` form asserts it.
|
|
112
|
+
return {
|
|
113
|
+
emojiKind: "custom",
|
|
114
|
+
emojiName: custom.name,
|
|
115
|
+
emojiId: custom.id,
|
|
116
|
+
...(custom.animated ? { emojiAnimated: true } : {}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return /^[\w+-]+(::skin-tone-[2-6])?$/.test(emoji)
|
|
120
|
+
? { emojiKind: "shortcode", emojiName: emoji }
|
|
121
|
+
: { emojiKind: "unicode", emojiName: emoji };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The typed identity of a reaction's emoji: the fields it declares when it
|
|
126
|
+
* declares a kind, otherwise the kind recovered from its spelling. Every
|
|
127
|
+
* reader that must know what an emoji is goes through this, so the wire
|
|
128
|
+
* contract and the web agree on when the spelling is consulted.
|
|
129
|
+
*/
|
|
130
|
+
export function reactionEmojiIdentity(
|
|
131
|
+
reaction: { emoji: string } & ReactionEmojiFields,
|
|
132
|
+
): ReactionEmojiIdentity {
|
|
133
|
+
return reaction.emojiKind !== undefined && reaction.emojiName !== undefined
|
|
134
|
+
? {
|
|
135
|
+
...pickReactionEmojiFields(reaction),
|
|
136
|
+
emojiKind: reaction.emojiKind,
|
|
137
|
+
emojiName: reaction.emojiName,
|
|
138
|
+
}
|
|
139
|
+
: classifyReactionEmojiSpelling(reaction.emoji);
|
|
140
|
+
}
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"./error": "./src/error.ts",
|
|
27
27
|
"./reactions": "./src/reactions.ts",
|
|
28
28
|
"./guardian-requests": "./src/guardian-requests.ts",
|
|
29
|
-
"./platform-credential": "./src/platform-credential.ts"
|
|
29
|
+
"./platform-credential": "./src/platform-credential.ts",
|
|
30
|
+
"./plan-credit": "./src/plan-credit.ts"
|
|
30
31
|
},
|
|
31
32
|
"scripts": {
|
|
32
33
|
"typecheck": "bunx tsc --noEmit",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
extraCreditUsd,
|
|
5
|
+
planCreditUsedFraction,
|
|
6
|
+
usageGrantRatio,
|
|
7
|
+
} from "../plan-credit.js";
|
|
8
|
+
|
|
9
|
+
describe("usageGrantRatio", () => {
|
|
10
|
+
test("reads the used share and clamps to 0..1", () => {
|
|
11
|
+
expect(usageGrantRatio(20, 9.1)).toBeCloseTo(0.545);
|
|
12
|
+
expect(usageGrantRatio(20, 0)).toBe(1);
|
|
13
|
+
expect(usageGrantRatio(20, 25)).toBe(0);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("has no reading without both figures or with nothing granted", () => {
|
|
17
|
+
expect(usageGrantRatio(null, 5)).toBeNull();
|
|
18
|
+
expect(usageGrantRatio(20, null)).toBeNull();
|
|
19
|
+
expect(usageGrantRatio(0, 0)).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("planCreditUsedFraction", () => {
|
|
24
|
+
test("prefers the ratio whatever the plan", () => {
|
|
25
|
+
expect(planCreditUsedFraction(20, 9.1, "base")).toBeCloseTo(0.545);
|
|
26
|
+
expect(planCreditUsedFraction(20, 9.1, null)).toBeCloseTo(0.545);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("treats a Pro plan with nothing granted as fully spent", () => {
|
|
30
|
+
expect(planCreditUsedFraction(0, 0, "pro")).toBe(1);
|
|
31
|
+
expect(planCreditUsedFraction(0, null, "pro")).toBe(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("gives a base or unknown plan with nothing granted no reading", () => {
|
|
35
|
+
expect(planCreditUsedFraction(0, 0, "base")).toBeNull();
|
|
36
|
+
expect(planCreditUsedFraction(0, 0, null)).toBeNull();
|
|
37
|
+
expect(planCreditUsedFraction(null, null, "pro")).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("extraCreditUsd", () => {
|
|
42
|
+
test("nets unused grant credit out of the balance to the cent", () => {
|
|
43
|
+
expect(extraCreditUsd(42.17, 9.1)).toBe(33.07);
|
|
44
|
+
expect(extraCreditUsd(34.65, 9.1)).toBe(25.55);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("never goes below zero", () => {
|
|
48
|
+
expect(extraCreditUsd(5, 9.1)).toBe(0);
|
|
49
|
+
expect(extraCreditUsd(-2.5, 0)).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -28,6 +28,7 @@ export * from "./rpc.js";
|
|
|
28
28
|
export * from "./trust-rules.js";
|
|
29
29
|
export * from "./ingress.js";
|
|
30
30
|
export * from "./no-response.js";
|
|
31
|
+
export * from "./plan-credit.js";
|
|
31
32
|
export * from "./platform-credential.js";
|
|
32
33
|
export * from "./remote-web-pairing.js";
|
|
33
34
|
export * from "./twilio-ingress.js";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan-credit derivations shared by the assistant runtime's `platform credits`
|
|
3
|
+
* route and the web client's usage meter, so both read the billing summary's
|
|
4
|
+
* grant figures the same way and can never quote different readings.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type BillingPlanId = "base" | "pro";
|
|
8
|
+
|
|
9
|
+
function clamp01(value: number): number {
|
|
10
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
return value > 1 ? 1 : value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How much of the usage credit an account was granted it has already used:
|
|
18
|
+
* the granted total less what is still unused, over that total. The initial
|
|
19
|
+
* $5 grant burns 0 to 100% as it is spent, and a further grant grows the total
|
|
20
|
+
* so the bar drops back. Null when nothing was ever granted, or when the
|
|
21
|
+
* platform reports neither figure, which has no honest reading rather than a
|
|
22
|
+
* full or empty bar.
|
|
23
|
+
*/
|
|
24
|
+
export function usageGrantRatio(
|
|
25
|
+
totalUsd: number | null,
|
|
26
|
+
availableUsd: number | null,
|
|
27
|
+
): number | null {
|
|
28
|
+
if (totalUsd == null || availableUsd == null || totalUsd <= 0) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return clamp01((totalUsd - availableUsd) / totalUsd);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The plan-credit reading with the plan's fallback applied. The grant figures
|
|
36
|
+
* count only unexpired grants, so a Pro subscription whose grants total
|
|
37
|
+
* nothing has used or expired everything it was granted (a full bar, with
|
|
38
|
+
* nothing scheduled to refill it), while a base plan in that position was
|
|
39
|
+
* never granted anything (no reading). An unknown plan gets the ratio when
|
|
40
|
+
* the figures allow one and no fallback otherwise.
|
|
41
|
+
*/
|
|
42
|
+
export function planCreditUsedFraction(
|
|
43
|
+
totalUsd: number | null,
|
|
44
|
+
availableUsd: number | null,
|
|
45
|
+
planId: BillingPlanId | null,
|
|
46
|
+
): number | null {
|
|
47
|
+
const ratio = usageGrantRatio(totalUsd, availableUsd);
|
|
48
|
+
if (ratio != null) {
|
|
49
|
+
return ratio;
|
|
50
|
+
}
|
|
51
|
+
if (planId === "pro" && totalUsd != null && totalUsd <= 0) {
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Credit bought or earned on top of the plan-included grants: the balance less
|
|
59
|
+
* whatever is still unused on those grants, never below zero, to the cent.
|
|
60
|
+
*/
|
|
61
|
+
export function extraCreditUsd(
|
|
62
|
+
balanceUsd: number,
|
|
63
|
+
availableUsd: number,
|
|
64
|
+
): number {
|
|
65
|
+
return Math.round(Math.max(0, balanceUsd - availableUsd) * 100) / 100;
|
|
66
|
+
}
|
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
* is spelled. Modelled on Zulip's `reaction_type`, the one surveyed system
|
|
8
8
|
* that separates the namespace from the name.
|
|
9
9
|
*
|
|
10
|
-
* `shortcode` is a name in a channel's own namespace
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* `shortcode` is a name in a channel's own namespace that the channel's
|
|
11
|
+
* adapter could not place among the standard emoji. Slack sends `+1` and
|
|
12
|
+
* `blob_wave` alike, as names; its normalizer resolves the standard ones to
|
|
13
|
+
* `unicode` from Slack's own list, and what remains is a workspace upload
|
|
14
|
+
* only the workspace can render. It is a distinct kind from `unicode`, not
|
|
15
|
+
* a stand-in for an unknown one, and nothing past an adapter needs a
|
|
16
|
+
* channel's naming to read it.
|
|
15
17
|
*/
|
|
16
18
|
import { z } from "zod";
|
|
17
19
|
|
|
@@ -39,6 +41,12 @@ export const ReactionEmojiFieldsSchema = z.object({
|
|
|
39
41
|
});
|
|
40
42
|
export type ReactionEmojiFields = z.infer<typeof ReactionEmojiFieldsSchema>;
|
|
41
43
|
|
|
44
|
+
/** The typed fields with the kind and name known. */
|
|
45
|
+
export type ReactionEmojiIdentity = ReactionEmojiFields & {
|
|
46
|
+
emojiKind: ReactionEmojiKind;
|
|
47
|
+
emojiName: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
42
50
|
/**
|
|
43
51
|
* The typed emoji fields a source actually carries, with undefined ones
|
|
44
52
|
* omitted: an absent key and a present-but-undefined one serialize alike, but the
|
|
@@ -58,3 +66,75 @@ export function pickReactionEmojiFields(
|
|
|
58
66
|
: {}),
|
|
59
67
|
};
|
|
60
68
|
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse Discord's custom-emoji mention form. The form travels the wire as a
|
|
72
|
+
* reaction's spelling, so more than one package reads it: the daemon rebuilds
|
|
73
|
+
* a REST path from it when the assistant reacts, and a row carrying only the
|
|
74
|
+
* spelling recovers its kind from it. One parser, so the two cannot disagree
|
|
75
|
+
* about what counts as one.
|
|
76
|
+
*
|
|
77
|
+
* `animated` reports whether the spelling carries the `a` marker. Discord's
|
|
78
|
+
* normalizer never writes that marker, so it is read only from spellings that
|
|
79
|
+
* arrive from elsewhere: a row written by another producer, or a value the
|
|
80
|
+
* model hands back as it received it.
|
|
81
|
+
*/
|
|
82
|
+
export function parseDiscordEmojiMention(
|
|
83
|
+
emoji: string,
|
|
84
|
+
): { name: string; id: string; animated: boolean } | null {
|
|
85
|
+
const match = /^<(a?):([^:>]+):(\d+)>$/.exec(emoji);
|
|
86
|
+
return match
|
|
87
|
+
? { name: match[2]!, id: match[3]!, animated: match[1] === "a" }
|
|
88
|
+
: null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Recover an emoji's kind from its spelling alone. This is the one inference
|
|
93
|
+
* the design permits, reserved for a value that carries the string and no
|
|
94
|
+
* typed fields: a persisted row or a replayed retry payload. A value that
|
|
95
|
+
* declares its kind never reaches this; `reactionEmojiIdentity` makes that
|
|
96
|
+
* choice for every reader.
|
|
97
|
+
*
|
|
98
|
+
* A mention form is unambiguous. Past that the two remaining kinds are told
|
|
99
|
+
* apart by whether the string is a name at all: a channel's shortcode is
|
|
100
|
+
* ASCII word characters, optionally carrying Slack's `::skin-tone-N`
|
|
101
|
+
* suffix, and anything else is the character itself.
|
|
102
|
+
*/
|
|
103
|
+
export function classifyReactionEmojiSpelling(
|
|
104
|
+
emoji: string,
|
|
105
|
+
): ReactionEmojiIdentity {
|
|
106
|
+
const custom = parseDiscordEmojiMention(emoji);
|
|
107
|
+
if (custom) {
|
|
108
|
+
// The plain `<:name:id>` form says nothing about animation: the
|
|
109
|
+
// normalizer spells every custom emoji that way and reports animation
|
|
110
|
+
// in the typed field instead, so a spelling without the `a` marker is
|
|
111
|
+
// "unrecorded", not "not animated". Only the `<a:` form asserts it.
|
|
112
|
+
return {
|
|
113
|
+
emojiKind: "custom",
|
|
114
|
+
emojiName: custom.name,
|
|
115
|
+
emojiId: custom.id,
|
|
116
|
+
...(custom.animated ? { emojiAnimated: true } : {}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return /^[\w+-]+(::skin-tone-[2-6])?$/.test(emoji)
|
|
120
|
+
? { emojiKind: "shortcode", emojiName: emoji }
|
|
121
|
+
: { emojiKind: "unicode", emojiName: emoji };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The typed identity of a reaction's emoji: the fields it declares when it
|
|
126
|
+
* declares a kind, otherwise the kind recovered from its spelling. Every
|
|
127
|
+
* reader that must know what an emoji is goes through this, so the wire
|
|
128
|
+
* contract and the web agree on when the spelling is consulted.
|
|
129
|
+
*/
|
|
130
|
+
export function reactionEmojiIdentity(
|
|
131
|
+
reaction: { emoji: string } & ReactionEmojiFields,
|
|
132
|
+
): ReactionEmojiIdentity {
|
|
133
|
+
return reaction.emojiKind !== undefined && reaction.emojiName !== undefined
|
|
134
|
+
? {
|
|
135
|
+
...pickReactionEmojiFields(reaction),
|
|
136
|
+
emojiKind: reaction.emojiKind,
|
|
137
|
+
emojiName: reaction.emojiName,
|
|
138
|
+
}
|
|
139
|
+
: classifyReactionEmojiSpelling(reaction.emoji);
|
|
140
|
+
}
|