@oharness/server 0.9.3 → 0.11.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/dist/analytics.d.ts +32 -29
- package/dist/analytics.js +225 -278
- package/dist/analytics.js.map +1 -1
- package/dist/i18n.js +500 -64
- package/dist/i18n.js.map +1 -1
- package/dist/icons.js +1 -0
- package/dist/icons.js.map +1 -1
- package/dist/main.js +4 -0
- package/dist/main.js.map +1 -1
- package/dist/model-order.js +1 -0
- package/dist/model-order.js.map +1 -1
- package/dist/page/client/account.js +570 -27
- package/dist/page/client/account.js.map +1 -1
- package/dist/page/client/base.js +55 -12
- package/dist/page/client/base.js.map +1 -1
- package/dist/page/client/browser.js +1 -1
- package/dist/page/client/composer.js +802 -59
- package/dist/page/client/composer.js.map +1 -1
- package/dist/page/client/computer.d.ts +27 -0
- package/dist/page/client/computer.js +358 -0
- package/dist/page/client/computer.js.map +1 -0
- package/dist/page/client/dialogs.js +168 -6
- package/dist/page/client/dialogs.js.map +1 -1
- package/dist/page/client/events.js +119 -9
- package/dist/page/client/events.js.map +1 -1
- package/dist/page/client/files.js +86 -1
- package/dist/page/client/files.js.map +1 -1
- package/dist/page/client/index.js +8 -0
- package/dist/page/client/index.js.map +1 -1
- package/dist/page/client/panels.js +14 -10
- package/dist/page/client/panels.js.map +1 -1
- package/dist/page/client/ripple.d.ts +13 -0
- package/dist/page/client/ripple.js +247 -0
- package/dist/page/client/ripple.js.map +1 -0
- package/dist/page/client/routines.js +432 -45
- package/dist/page/client/routines.js.map +1 -1
- package/dist/page/client/shell.js +386 -104
- package/dist/page/client/shell.js.map +1 -1
- package/dist/page/client/state.js +278 -19
- package/dist/page/client/state.js.map +1 -1
- package/dist/page/client/tasks.d.ts +8 -0
- package/dist/page/client/tasks.js +293 -0
- package/dist/page/client/tasks.js.map +1 -0
- package/dist/page/client/terminal.js +34 -7
- package/dist/page/client/terminal.js.map +1 -1
- package/dist/page/client/theme.d.ts +17 -0
- package/dist/page/client/theme.js +365 -0
- package/dist/page/client/theme.js.map +1 -0
- package/dist/page/client/transcript.js +196 -8
- package/dist/page/client/transcript.js.map +1 -1
- package/dist/page/client/usage.js +1 -11
- package/dist/page/client/usage.js.map +1 -1
- package/dist/page/style.js +745 -76
- package/dist/page/style.js.map +1 -1
- package/dist/page.js +94 -16
- package/dist/page.js.map +1 -1
- package/dist/prefs.d.ts +31 -0
- package/dist/prefs.js +13 -1
- package/dist/prefs.js.map +1 -1
- package/dist/remote-crypto.d.ts +78 -0
- package/dist/remote-crypto.js +216 -0
- package/dist/remote-crypto.js.map +1 -0
- package/dist/remote.d.ts +103 -0
- package/dist/remote.js +498 -0
- package/dist/remote.js.map +1 -0
- package/dist/server.d.ts +70 -0
- package/dist/server.js +1738 -73
- package/dist/server.js.map +1 -1
- package/dist/starters.d.ts +92 -0
- package/dist/starters.js +213 -0
- package/dist/starters.js.map +1 -0
- package/dist/suggest.d.ts +42 -0
- package/dist/suggest.js +215 -0
- package/dist/suggest.js.map +1 -0
- package/dist/terminal.d.ts +28 -2
- package/dist/terminal.js +45 -3
- package/dist/terminal.js.map +1 -1
- package/dist/theme-assets.d.ts +39 -0
- package/dist/theme-assets.js +109 -0
- package/dist/theme-assets.js.map +1 -0
- package/dist/theme-store.d.ts +50 -0
- package/dist/theme-store.js +124 -0
- package/dist/theme-store.js.map +1 -0
- package/dist/theme-tools.d.ts +25 -0
- package/dist/theme-tools.js +243 -0
- package/dist/theme-tools.js.map +1 -0
- package/dist/turn-shots.d.ts +43 -0
- package/dist/turn-shots.js +80 -0
- package/dist/turn-shots.js.map +1 -0
- package/dist/user-theme.d.ts +143 -0
- package/dist/user-theme.js +510 -0
- package/dist/user-theme.js.map +1 -0
- package/package.json +2 -2
|
@@ -215,6 +215,21 @@ async function showModels() {
|
|
|
215
215
|
return go;
|
|
216
216
|
};
|
|
217
217
|
|
|
218
|
+
// What this vendor has cost lately, from this machine's own records — the
|
|
219
|
+
// question a pasted key leaves open, answered where the key is.
|
|
220
|
+
if (provider.spend30d >= 0.01) {
|
|
221
|
+
const spent = el("div", "muted small");
|
|
222
|
+
spent.appendChild(document.createTextNode(t("models.spend30d", { amount: moneyOf(Math.round(provider.spend30d * 100), "usd") }) + " "));
|
|
223
|
+
const more = el("button", "cn-inline", t("models.spendDetail"));
|
|
224
|
+
more.type = "button";
|
|
225
|
+
more.addEventListener("click", (e) => {
|
|
226
|
+
e.stopPropagation();
|
|
227
|
+
document.getElementById("nav-usage").click();
|
|
228
|
+
});
|
|
229
|
+
spent.appendChild(more);
|
|
230
|
+
cardBody.appendChild(spent);
|
|
231
|
+
}
|
|
232
|
+
|
|
218
233
|
if (provider.readiness === "no-account-system") {
|
|
219
234
|
cardBody.appendChild(el("div", "muted small", t("models.noAccounts")));
|
|
220
235
|
cardBody.appendChild(el("div", "muted small", t("models.noAccountsHow")));
|
|
@@ -229,6 +244,8 @@ async function showModels() {
|
|
|
229
244
|
cardBody.appendChild(signInButton());
|
|
230
245
|
} else if (provider.signIn && (!provider.configured || String(provider.source || "").startsWith("env:"))) {
|
|
231
246
|
cardBody.appendChild(signInButton());
|
|
247
|
+
} else if (provider.vendorSignIn && !provider.configured) {
|
|
248
|
+
cardBody.appendChild(vendorSignIn(provider, null, null));
|
|
232
249
|
}
|
|
233
250
|
|
|
234
251
|
// The key field, for every vendor that takes one — the account plans
|
|
@@ -236,7 +253,7 @@ async function showModels() {
|
|
|
236
253
|
// and spends the same balance, and CI needs one because it does not
|
|
237
254
|
// expire. This is where it belongs, next to every other vendor's key
|
|
238
255
|
// field, rather than inside the sign-in dialog.
|
|
239
|
-
if (!provider.configured || String(provider.source || "").startsWith("env:")) {
|
|
256
|
+
if (!keyless(provider) && (!provider.configured || String(provider.source || "").startsWith("env:"))) {
|
|
240
257
|
const row = el("div", "keyrow");
|
|
241
258
|
const field = document.createElement("input");
|
|
242
259
|
// type=password so the key is not left legible on a screen, and
|
|
@@ -462,7 +479,17 @@ function openConnect(providerId, thenModel) {
|
|
|
462
479
|
// the providers that are one. For a vendor whose credential is a key it
|
|
463
480
|
// would be an advertisement in the way of the field they came to fill in.
|
|
464
481
|
document.getElementById("cn-aside").hidden = !provider.signIn;
|
|
465
|
-
|
|
482
|
+
// The column beside it already names OHarness; the title says what this
|
|
483
|
+
// side is for instead of naming it a second time.
|
|
484
|
+
document.getElementById("cn-title").textContent = provider.signIn && provider.readiness !== "no-account-system"
|
|
485
|
+
? t("models.signIn")
|
|
486
|
+
: provider.label || provider.id;
|
|
487
|
+
// A vendor's own sign-in is that vendor's page the user is about to visit,
|
|
488
|
+
// so the dialog carries its mark: recognising the logo is most of what
|
|
489
|
+
// makes "sign in with" feel safe to click.
|
|
490
|
+
if (!provider.signIn && provider.vendorSignIn) {
|
|
491
|
+
document.getElementById("cn-title").prepend(providerMark(provider.id, provider.label || provider.id, "cn-title-mark"));
|
|
492
|
+
}
|
|
466
493
|
// What this dialog is answering. A credential that ran out and one that was
|
|
467
494
|
// never created both arrive here, and telling someone to "sign in" when what
|
|
468
495
|
// happened is "your session ended" leaves them wondering whether the first
|
|
@@ -590,6 +617,54 @@ function openConnect(providerId, thenModel) {
|
|
|
590
617
|
const siteButton = site.querySelector("button");
|
|
591
618
|
const orCode = el("div", "cn-note", ts("models.orCode"));
|
|
592
619
|
|
|
620
|
+
// A vendor's own sign-in, above the key field it makes optional — or, for a
|
|
621
|
+
// vendor with no key at all, in place of it.
|
|
622
|
+
const vendor = mode === "key" && provider.vendorSignIn
|
|
623
|
+
? vendorSignIn(
|
|
624
|
+
provider,
|
|
625
|
+
() => {
|
|
626
|
+
if (dialog.open) void done("signed in to " + provider.id + " — discovering its models…");
|
|
627
|
+
},
|
|
628
|
+
(reason) => {
|
|
629
|
+
hint.textContent = reason;
|
|
630
|
+
hint.hidden = false;
|
|
631
|
+
},
|
|
632
|
+
)
|
|
633
|
+
: null;
|
|
634
|
+
const noKey = keyless(provider);
|
|
635
|
+
const orKey = vendor && !noKey ? el("div", "cn-note", t("models.orPasteKey")) : null;
|
|
636
|
+
// What signing in hands over, said where the hesitation is: right under the
|
|
637
|
+
// button. Only when the sign-in is the whole dialog — beside a key field the
|
|
638
|
+
// key's own note says the same, and two padlocks in a row read as clutter.
|
|
639
|
+
let vendorLocal = null;
|
|
640
|
+
if (vendor && noKey) {
|
|
641
|
+
vendorLocal = el("div", "cn-note cn-local");
|
|
642
|
+
vendorLocal.appendChild(svgIcon("lock"));
|
|
643
|
+
const words = el("span");
|
|
644
|
+
words.appendChild(el("strong", null, t("models.vendorLocalLead", { provider: provider.label || provider.id })));
|
|
645
|
+
words.appendChild(document.createTextNode(t("models.vendorLocalRest")));
|
|
646
|
+
vendorLocal.appendChild(words);
|
|
647
|
+
}
|
|
648
|
+
// Someone with a Claude plan arrives here looking for a way to use it, and
|
|
649
|
+
// a key field alone says nothing to them. The plan works in Claude Code and
|
|
650
|
+
// not in other apps, so the honest answer is a door to Claude Code — their
|
|
651
|
+
// own install, in this project's terminal — rather than a login of ours.
|
|
652
|
+
let claudePlan = null;
|
|
653
|
+
if (provider.id === "anthropic" && currentCwd) {
|
|
654
|
+
claudePlan = el("div", "cn-note");
|
|
655
|
+
claudePlan.appendChild(document.createTextNode(t("models.claudePlan") + " "));
|
|
656
|
+
const open = el("button", "cn-inline", t("models.claudePlanOpen"));
|
|
657
|
+
open.type = "button";
|
|
658
|
+
open.onclick = () => {
|
|
659
|
+
dialog.close();
|
|
660
|
+
void openClaudeCode(currentCwd);
|
|
661
|
+
};
|
|
662
|
+
claudePlan.appendChild(open);
|
|
663
|
+
}
|
|
664
|
+
// The sign-in is the whole dialog then, and the × already closes it: a
|
|
665
|
+
// Cancel alone in the actions row only read as a button left behind.
|
|
666
|
+
dialog.classList.toggle("cn-keyless", noKey);
|
|
667
|
+
|
|
593
668
|
/**
|
|
594
669
|
* What the dialog opens focused.
|
|
595
670
|
*
|
|
@@ -602,7 +677,13 @@ function openConnect(providerId, thenModel) {
|
|
|
602
677
|
// no account system it is the one that fetches a key, which is the same
|
|
603
678
|
// shape of thing and deserves the same treatment. The field is where the
|
|
604
679
|
// cursor goes only when pasting is what is left.
|
|
605
|
-
const opener = mode === "account" && !flow
|
|
680
|
+
const opener = mode === "account" && !flow
|
|
681
|
+
? siteButton
|
|
682
|
+
: noAccounts
|
|
683
|
+
? keyButton
|
|
684
|
+
: vendor
|
|
685
|
+
? vendor.querySelector("button")
|
|
686
|
+
: undefined;
|
|
606
687
|
if (opener) opener.focus();
|
|
607
688
|
else field.focus();
|
|
608
689
|
};
|
|
@@ -661,7 +742,16 @@ function openConnect(providerId, thenModel) {
|
|
|
661
742
|
// button above the field: whichever control is the way in carries the
|
|
662
743
|
// colour, and the actions row holds the fallback. Two filled buttons make
|
|
663
744
|
// the reader choose by position, and position would pick the wrong one.
|
|
664
|
-
save.classList.toggle("approve", mode === "key" && !noAccounts);
|
|
745
|
+
save.classList.toggle("approve", mode === "key" && !noAccounts && !vendor);
|
|
746
|
+
// Nothing to paste for a vendor that takes no key: the sign-in above is
|
|
747
|
+
// the whole dialog, and a field beside it could only ever be refused.
|
|
748
|
+
field.hidden = noKey;
|
|
749
|
+
save.hidden = noKey;
|
|
750
|
+
if (noKey) {
|
|
751
|
+
why.hidden = true;
|
|
752
|
+
keptLocal.hidden = true;
|
|
753
|
+
docs.hidden = true;
|
|
754
|
+
}
|
|
665
755
|
field.value = "";
|
|
666
756
|
focusPrimary();
|
|
667
757
|
};
|
|
@@ -814,10 +904,14 @@ function openConnect(providerId, thenModel) {
|
|
|
814
904
|
// somebody expecting a sign-in would ask — and they are exactly who opens
|
|
815
905
|
// the mark.
|
|
816
906
|
...(noAccounts ? [keyLink(), el("div", "cn-note", t("models.orPasteKey"))] : []),
|
|
907
|
+
...(vendor ? [vendor] : []),
|
|
908
|
+
...(vendorLocal ? [vendorLocal] : []),
|
|
909
|
+
...(orKey ? [orKey] : []),
|
|
817
910
|
site,
|
|
818
911
|
orCode,
|
|
819
912
|
field,
|
|
820
913
|
keptLocal,
|
|
914
|
+
...(claudePlan ? [claudePlan] : []),
|
|
821
915
|
);
|
|
822
916
|
// Re-attached on every open: the actions row is markup, so a link left from
|
|
823
917
|
// the last vendor would otherwise point at their key page. The link is built
|
|
@@ -978,7 +1072,7 @@ async function loadAccount() {
|
|
|
978
1072
|
const name = body.name || body.email || t("account.signedIn");
|
|
979
1073
|
row.appendChild(avatarFor(name, body.avatar));
|
|
980
1074
|
row.appendChild(el("span", "who-name", name));
|
|
981
|
-
row.appendChild(svgIcon("
|
|
1075
|
+
row.appendChild(svgIcon("caret", "acct-caret"));
|
|
982
1076
|
|
|
983
1077
|
// The balance, once it can be read. Running out mid-turn is the failure this
|
|
984
1078
|
// prevents: a number that appears only in a paywall is a number nobody sees
|
|
@@ -1016,6 +1110,7 @@ async function loadAccount() {
|
|
|
1016
1110
|
return found ? found.name : t("account.teamUnnamed");
|
|
1017
1111
|
};
|
|
1018
1112
|
void getJson("/billing").then((state) => {
|
|
1113
|
+
if (state && !state.error) lastBilling = state;
|
|
1019
1114
|
if (!state || !state.signedIn || state.unavailable) return;
|
|
1020
1115
|
sellsCredits = true;
|
|
1021
1116
|
// The plan, from the same answer the balance comes out of: "0 credits"
|
|
@@ -1156,8 +1251,31 @@ async function loadAccount() {
|
|
|
1156
1251
|
// Whose credits the next call spends: Personal, or a team the account is
|
|
1157
1252
|
// on, the current one ticked the way the model menu ticks its choice.
|
|
1158
1253
|
// Only once there is a team to choose, since a list of one is no choice.
|
|
1254
|
+
// The team page on the site, in the real browser, where a team is started,
|
|
1255
|
+
// its people invited and its seats bought.
|
|
1256
|
+
const openTeams = () => {
|
|
1257
|
+
closeRowMenu();
|
|
1258
|
+
window.open(body.site + "/" + LOCALE + "/dashboard/team", "_blank", "noopener");
|
|
1259
|
+
};
|
|
1159
1260
|
if (knownTeams.length) {
|
|
1160
|
-
|
|
1261
|
+
// Managing them sits on the section's own heading, at its right, rather
|
|
1262
|
+
// than as one more row that reads like a workspace to switch to.
|
|
1263
|
+
// The heading and the choices under it on one faint surface; the one
|
|
1264
|
+
// in use is marked by a stronger fill (see .menu-choices).
|
|
1265
|
+
const group = el("div", "menu-choices");
|
|
1266
|
+
const head = el("div", "menu-head menu-section menu-section-row");
|
|
1267
|
+
head.appendChild(el("span", null, t("account.workspace")));
|
|
1268
|
+
if (body.site) {
|
|
1269
|
+
const manage = el("button", "menu-section-action", t("account.manage"));
|
|
1270
|
+
manage.type = "button";
|
|
1271
|
+
manage.title = t("account.manageTeams");
|
|
1272
|
+
manage.addEventListener("click", (e) => {
|
|
1273
|
+
e.stopPropagation();
|
|
1274
|
+
openTeams();
|
|
1275
|
+
});
|
|
1276
|
+
head.appendChild(manage);
|
|
1277
|
+
}
|
|
1278
|
+
group.appendChild(head);
|
|
1161
1279
|
const choice = (label, id) => {
|
|
1162
1280
|
const chosen = (body.team || null) === id;
|
|
1163
1281
|
const item = el("button", "menu-item" + (chosen ? " on" : ""));
|
|
@@ -1167,22 +1285,14 @@ async function loadAccount() {
|
|
|
1167
1285
|
item.addEventListener("click", () => (chosen ? closeRowMenu() : billTo(id)));
|
|
1168
1286
|
return item;
|
|
1169
1287
|
};
|
|
1170
|
-
|
|
1171
|
-
knownTeams.forEach((team) =>
|
|
1288
|
+
group.appendChild(choice(t("account.personal"), null));
|
|
1289
|
+
knownTeams.forEach((team) => group.appendChild(choice(team.name, team.id)));
|
|
1290
|
+
items.push(group);
|
|
1172
1291
|
}
|
|
1173
|
-
// The way to a workspace at all, for
|
|
1174
|
-
//
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
if (body.site) {
|
|
1178
|
-
items.push(
|
|
1179
|
-
menuItem(t(knownTeams.length ? "account.manageTeams" : "account.createTeam"), {
|
|
1180
|
-
run: () => {
|
|
1181
|
-
closeRowMenu();
|
|
1182
|
-
window.open(body.site + "/" + LOCALE + "/dashboard/team", "_blank", "noopener");
|
|
1183
|
-
},
|
|
1184
|
-
}),
|
|
1185
|
-
);
|
|
1292
|
+
// The way to a workspace at all, for an account on none. Once there is one,
|
|
1293
|
+
// the section heading above carries Manage instead.
|
|
1294
|
+
if (body.site && !knownTeams.length) {
|
|
1295
|
+
items.push(menuItem(t("account.createTeam"), { run: openTeams }));
|
|
1186
1296
|
}
|
|
1187
1297
|
// The two pages this menu had no way to reach, under the workspace rows
|
|
1188
1298
|
// they belong beside: what a team costs and how to ask for the parts of it
|
|
@@ -1190,6 +1300,16 @@ async function loadAccount() {
|
|
|
1190
1300
|
// in the real browser like every other outbound item here — in the desktop
|
|
1191
1301
|
// build the window handler routes them there rather than loading the site
|
|
1192
1302
|
// inside the window the session lives in.
|
|
1303
|
+
// Feedback goes to the site as this account, so it lives in the menu that
|
|
1304
|
+
// only exists while one is signed in.
|
|
1305
|
+
items.push(
|
|
1306
|
+
menuItem(t("feedback.menu"), {
|
|
1307
|
+
run: () => {
|
|
1308
|
+
closeRowMenu();
|
|
1309
|
+
openFeedback();
|
|
1310
|
+
},
|
|
1311
|
+
}),
|
|
1312
|
+
);
|
|
1193
1313
|
if (body.site) {
|
|
1194
1314
|
items.push(
|
|
1195
1315
|
menuItem(t("account.enterprise"), {
|
|
@@ -1215,6 +1335,7 @@ async function loadAccount() {
|
|
|
1215
1335
|
cls: "accent",
|
|
1216
1336
|
}),
|
|
1217
1337
|
themeRow(),
|
|
1338
|
+
...customThemeRows(),
|
|
1218
1339
|
menuItem(t("account.signOut"), {
|
|
1219
1340
|
run: () => {
|
|
1220
1341
|
closeRowMenu();
|
|
@@ -1284,6 +1405,51 @@ function websiteSignIn(provider, onSignedIn, onUnavailable) {
|
|
|
1284
1405
|
return row;
|
|
1285
1406
|
}
|
|
1286
1407
|
|
|
1408
|
+
/**
|
|
1409
|
+
* A vendor whose only way in is its browser sign-in — ChatGPT, whose plan has
|
|
1410
|
+
* no key to paste. Offering a key field for it would be a dead end.
|
|
1411
|
+
*/
|
|
1412
|
+
function keyless(provider) {
|
|
1413
|
+
return Boolean(provider.vendorSignIn) && !(provider.envVars || []).length;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* A vendor's own browser sign-in: Sign in with ChatGPT, OpenRouter.
|
|
1418
|
+
*
|
|
1419
|
+
* Like the website sign-in, the answer does not come back to the click. The
|
|
1420
|
+
* vendor redirects to a listener on this machine, the server finishes the
|
|
1421
|
+
* login there, and models_changed is what tells this page — so the waiter is
|
|
1422
|
+
* registered only once the tab has actually been opened.
|
|
1423
|
+
*/
|
|
1424
|
+
function vendorSignIn(provider, onSignedIn, onError) {
|
|
1425
|
+
const row = el("div", "keyrow");
|
|
1426
|
+
const go = el("button", "signin-site", provider.vendorSignIn);
|
|
1427
|
+
go.type = "button";
|
|
1428
|
+
go.addEventListener("click", (e) => {
|
|
1429
|
+
e.preventDefault();
|
|
1430
|
+
e.stopPropagation();
|
|
1431
|
+
go.disabled = true;
|
|
1432
|
+
void api("/signin/vendor", { provider: provider.id })
|
|
1433
|
+
.then((result) => {
|
|
1434
|
+
if (!result || result.error) {
|
|
1435
|
+
const message = (result && result.error) || "could not start the sign-in";
|
|
1436
|
+
if (onError) return void onError(message);
|
|
1437
|
+
return void addNotice(message, "err");
|
|
1438
|
+
}
|
|
1439
|
+
signedInWaiters.set(provider.id, onSignedIn || (() => {
|
|
1440
|
+
if (document.getElementById("nav-models").classList.contains("active")) void showModels();
|
|
1441
|
+
}));
|
|
1442
|
+
window.open(result.url, "_blank", "noopener");
|
|
1443
|
+
addNotice(t("models.signInOpened", { site: provider.label || provider.id }));
|
|
1444
|
+
})
|
|
1445
|
+
.finally(() => {
|
|
1446
|
+
go.disabled = false;
|
|
1447
|
+
});
|
|
1448
|
+
});
|
|
1449
|
+
row.appendChild(go);
|
|
1450
|
+
return row;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1287
1453
|
/**
|
|
1288
1454
|
* The sign-in itself, without a button attached to it.
|
|
1289
1455
|
*
|
|
@@ -1420,6 +1586,67 @@ function applyTheme() {
|
|
|
1420
1586
|
applyTheme();
|
|
1421
1587
|
|
|
1422
1588
|
let pwInterval = "month";
|
|
1589
|
+
/**
|
|
1590
|
+
* The last billing answer this window saw — the account menu asks for it when
|
|
1591
|
+
* the account loads, and the paywall on every poll. The paywall opens on it
|
|
1592
|
+
* rather than on "Checking the balance…": the plans and their prices do not
|
|
1593
|
+
* change between one minute and the next, and a dialog that makes someone
|
|
1594
|
+
* wait for a price list it already had reads as slow. The fresh answer
|
|
1595
|
+
* replaces it the moment it lands.
|
|
1596
|
+
*/
|
|
1597
|
+
let lastBilling = null;
|
|
1598
|
+
// Set by a click on the Yearly tab and spent by the render that follows it:
|
|
1599
|
+
// the yearly prices count down from twelve months at the monthly price once,
|
|
1600
|
+
// when the reader asks for the year. Not on every repaint — the balance poll
|
|
1601
|
+
// redraws this body every few seconds, and a number that kept counting would
|
|
1602
|
+
// read as a price that kept changing.
|
|
1603
|
+
let pwCount = false;
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* The models a plan's credits reach, drawn as marks and names above the plans:
|
|
1607
|
+
* someone stopped mid-conversation by this dialog is deciding whether the
|
|
1608
|
+
* money buys the models they care about, and a name with its mark answers
|
|
1609
|
+
* that faster than a sentence. Named here rather than fetched: this screen has
|
|
1610
|
+
* only the billing catalogue, which knows plans and not models, and these are
|
|
1611
|
+
* the ones the site's pricing page leads with. The marks are inline for the
|
|
1612
|
+
* reason the model menu draws ours inline — no request to anyone to open a
|
|
1613
|
+
* paywall.
|
|
1614
|
+
*/
|
|
1615
|
+
const PW_MODELS = [
|
|
1616
|
+
{
|
|
1617
|
+
key: "paywall.chat",
|
|
1618
|
+
models: [
|
|
1619
|
+
{ label: "GPT-6 Astra", svg: '<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24"><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"/></svg>' },
|
|
1620
|
+
{ label: "Opus 5.5", svg: '<svg viewBox="0 0 24 24"><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"/></svg>' },
|
|
1621
|
+
{ label: "DeepSeek V4.1", svg: '<svg viewBox="0 0 24 24"><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"/></svg>' },
|
|
1622
|
+
{ label: "Kimi K3", svg: '<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24"><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z"/><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z"/></svg>' },
|
|
1623
|
+
],
|
|
1624
|
+
},
|
|
1625
|
+
{
|
|
1626
|
+
key: "paywall.media",
|
|
1627
|
+
models: [
|
|
1628
|
+
{ label: "GPT Image 2.5", svg: '<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24"><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"/></svg>' },
|
|
1629
|
+
{ label: "Seedance 2.5", svg: '<svg viewBox="0 0 24 24"><path d="M14.944 18.587l-1.704-.445V10.01l1.824-.462c1-.254 1.84-.461 1.88-.453.032 0 .056 2.235.056 4.972v4.973l-.176-.008c-.104 0-.952-.207-1.88-.446z" fill="#00C8D2" fill-rule="nonzero"/><path d="M7 16.542c0-2.736.024-4.98.064-4.98.032-.008.872.2 1.88.454l1.816.461-.016 4.05-.024 4.049-1.632.422c-.896.23-1.736.445-1.856.469L7 21.523v-4.98z" fill="#3C8CFF" fill-rule="nonzero"/><path d="M19.24 12.477c0-9.03.008-9.515.144-9.475.072.024.784.207 1.576.406.792.207 1.576.405 1.744.445l.296.08-.016 8.56-.024 8.568-1.624.414c-.888.23-1.728.437-1.856.47l-.24.055v-9.523z" fill="#78E6DC" fill-rule="nonzero"/><path d="M1 12.509c0-4.678.024-8.505.064-8.505.032 0 .872.207 1.872.454l1.824.461v7.582c0 4.16-.016 7.574-.032 7.574-.024 0-.872.215-1.88.47L1 21.013v-8.505z" fill="#325AB4"/></svg>' },
|
|
1630
|
+
],
|
|
1631
|
+
},
|
|
1632
|
+
];
|
|
1633
|
+
|
|
1634
|
+
/** A price that counts down to its own text, once. Reduced motion skips it. */
|
|
1635
|
+
function countDown(node, fromCents, toCents, currency, text) {
|
|
1636
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
1637
|
+
const start = performance.now();
|
|
1638
|
+
const tick = (now) => {
|
|
1639
|
+
const p = Math.min(1, (now - start) / 700);
|
|
1640
|
+
const eased = 1 - Math.pow(1 - p, 3);
|
|
1641
|
+
// Whole units on the way down, so the cents do not flicker; the last
|
|
1642
|
+
// frame is the real string.
|
|
1643
|
+
const cents = Math.round((fromCents + (toCents - fromCents) * eased) / 100) * 100;
|
|
1644
|
+
if (!node.isConnected) return;
|
|
1645
|
+
node.textContent = p < 1 ? moneyOf(cents, currency) : text;
|
|
1646
|
+
if (p < 1) requestAnimationFrame(tick);
|
|
1647
|
+
};
|
|
1648
|
+
requestAnimationFrame(tick);
|
|
1649
|
+
}
|
|
1423
1650
|
|
|
1424
1651
|
/**
|
|
1425
1652
|
* @param buying Opened deliberately, rather than by a turn that could not be
|
|
@@ -1594,13 +1821,18 @@ function openPaywall(buying) {
|
|
|
1594
1821
|
* months at the same price, and a "Save 0%" badge on it would be a worse
|
|
1595
1822
|
* lie than silence.
|
|
1596
1823
|
*/
|
|
1597
|
-
|
|
1824
|
+
/** Twelve months of the same tier at the monthly price, where the year costs less. */
|
|
1825
|
+
const fullYearOf = (plan) => {
|
|
1598
1826
|
if (plan.interval !== "year" || !plan.tier) return 0;
|
|
1599
1827
|
const monthly = subs.find((p) => p.tier === plan.tier && p.interval === "month");
|
|
1600
1828
|
if (!monthly || typeof monthly.amountCents !== "number" || typeof plan.amountCents !== "number") return 0;
|
|
1829
|
+
if ((monthly.currency || "usd").toLowerCase() !== (plan.currency || "usd").toLowerCase()) return 0;
|
|
1601
1830
|
const full = monthly.amountCents * 12;
|
|
1602
|
-
|
|
1603
|
-
|
|
1831
|
+
return full > 0 && plan.amountCents < full ? full : 0;
|
|
1832
|
+
};
|
|
1833
|
+
const savingOn = (plan) => {
|
|
1834
|
+
const full = fullYearOf(plan);
|
|
1835
|
+
return full ? Math.round((1 - plan.amountCents / full) * 100) : 0;
|
|
1604
1836
|
};
|
|
1605
1837
|
|
|
1606
1838
|
/** One row: what it is, how many credits, and how much money. */
|
|
@@ -1618,7 +1850,19 @@ function openPaywall(buying) {
|
|
|
1618
1850
|
if (plan.popular) name.appendChild(el("span", "pw-tag", t("paywall.popular")));
|
|
1619
1851
|
top.appendChild(name);
|
|
1620
1852
|
const money = moneyOf(plan.amountCents, plan.currency);
|
|
1621
|
-
if (money)
|
|
1853
|
+
if (money) {
|
|
1854
|
+
const price = el("span", "pw-plan-price");
|
|
1855
|
+
// A yearly rung that saves shows what it saves from: twelve months at
|
|
1856
|
+
// the monthly price, struck through, and — right after the switch to
|
|
1857
|
+
// the year — the price counting down from that number to its own.
|
|
1858
|
+
const full = fullYearOf(plan);
|
|
1859
|
+
if (full) price.appendChild(el("s", "pw-was", moneyOf(full, plan.currency)));
|
|
1860
|
+
const amountNode = el("span", null, money);
|
|
1861
|
+
price.appendChild(amountNode);
|
|
1862
|
+
if (suffix) price.appendChild(document.createTextNode(suffix));
|
|
1863
|
+
if (full && pwCount) countDown(amountNode, full, plan.amountCents, plan.currency, money);
|
|
1864
|
+
top.appendChild(price);
|
|
1865
|
+
}
|
|
1622
1866
|
row.appendChild(top);
|
|
1623
1867
|
// The pack's own label is already "2,000 credits", so repeating the
|
|
1624
1868
|
// number underneath it said the same thing twice. Subscriptions need
|
|
@@ -1656,14 +1900,42 @@ function openPaywall(buying) {
|
|
|
1656
1900
|
const other = subs.filter((p) => p.interval !== "month" && p.interval !== "year");
|
|
1657
1901
|
|
|
1658
1902
|
if (subs.length) {
|
|
1903
|
+
// What the credits reach, before the plans that sell them: someone who
|
|
1904
|
+
// came here to keep chatting may not know the same balance runs the
|
|
1905
|
+
// other flagship models, and draws pictures and video too.
|
|
1906
|
+
const media = el("div", "pw-media");
|
|
1907
|
+
media.appendChild(el("div", "pw-media-head", t("paywall.included")));
|
|
1908
|
+
PW_MODELS.forEach((group) => {
|
|
1909
|
+
media.appendChild(el("span", "pw-media-kind", t(group.key)));
|
|
1910
|
+
const marks = el("span", "pw-media-models");
|
|
1911
|
+
group.models.forEach((model) => {
|
|
1912
|
+
const chip = el("span", "pw-media-model");
|
|
1913
|
+
const mark = el("span", "pw-media-mark");
|
|
1914
|
+
mark.innerHTML = model.svg;
|
|
1915
|
+
chip.appendChild(mark);
|
|
1916
|
+
chip.appendChild(document.createTextNode(model.label));
|
|
1917
|
+
marks.appendChild(chip);
|
|
1918
|
+
});
|
|
1919
|
+
media.appendChild(marks);
|
|
1920
|
+
});
|
|
1921
|
+
body.appendChild(media);
|
|
1922
|
+
|
|
1659
1923
|
if (intervals.length > 1) {
|
|
1660
1924
|
const toggle = el("div", "pw-toggle");
|
|
1925
|
+
// The best a year saves, on the tab itself: the reason to press it,
|
|
1926
|
+
// said before it is pressed.
|
|
1927
|
+
const best = Math.max(0, ...byInterval.year.map(savingOn));
|
|
1661
1928
|
intervals.forEach((key) => {
|
|
1662
1929
|
const tab = el("button", "pw-tab" + (key === pwInterval ? " on" : ""));
|
|
1663
1930
|
tab.type = "button";
|
|
1664
1931
|
tab.textContent = t(key === "year" ? "paywall.yearly" : "paywall.monthly");
|
|
1932
|
+
if (key === "year" && best > 0) {
|
|
1933
|
+
tab.appendChild(el("span", "pw-tab-save", t("paywall.upTo", { percent: best })));
|
|
1934
|
+
}
|
|
1665
1935
|
tab.onclick = () => {
|
|
1936
|
+
if (pwInterval === key) return;
|
|
1666
1937
|
pwInterval = key;
|
|
1938
|
+
pwCount = key === "year";
|
|
1667
1939
|
render(state);
|
|
1668
1940
|
};
|
|
1669
1941
|
toggle.appendChild(tab);
|
|
@@ -1675,6 +1947,7 @@ function openPaywall(buying) {
|
|
|
1675
1947
|
showing.concat(other).forEach((plan) => {
|
|
1676
1948
|
list.appendChild(planRow(plan, t(plan.interval === "year" ? "paywall.perYear" : "paywall.perMonth")));
|
|
1677
1949
|
});
|
|
1950
|
+
pwCount = false;
|
|
1678
1951
|
body.appendChild(list);
|
|
1679
1952
|
}
|
|
1680
1953
|
|
|
@@ -1725,6 +1998,7 @@ function openPaywall(buying) {
|
|
|
1725
1998
|
|
|
1726
1999
|
const check = () =>
|
|
1727
2000
|
getJson("/billing").then((state) => {
|
|
2001
|
+
if (state && !state.error) lastBilling = state;
|
|
1728
2002
|
if (!dialog.open) return;
|
|
1729
2003
|
if (!state || state.error) {
|
|
1730
2004
|
return void render({ signedIn: true, unavailable: "unreachable" });
|
|
@@ -1743,8 +2017,21 @@ function openPaywall(buying) {
|
|
|
1743
2017
|
render(state);
|
|
1744
2018
|
});
|
|
1745
2019
|
|
|
1746
|
-
|
|
1747
|
-
|
|
2020
|
+
// The cards straight away from what is already known. The balance too when
|
|
2021
|
+
// it was already zero — the usual case, and the line then does not jump in
|
|
2022
|
+
// above the cards a moment later — but not a positive one: that number is
|
|
2023
|
+
// stale by definition, since running out is why this opened, and render
|
|
2024
|
+
// leaves the line out when it is undefined. A team's screen is all its
|
|
2025
|
+
// pooled number, so that one waits for the fresh answer.
|
|
2026
|
+
const cached = lastBilling;
|
|
2027
|
+
if (cached && cached.signedIn && !cached.unavailable && !cached.team
|
|
2028
|
+
&& Array.isArray(cached.plans) && cached.plans.length) {
|
|
2029
|
+
if (!dialog.open) dialog.showModal();
|
|
2030
|
+
render({ ...cached, balanceMicro: Number(cached.balanceMicro) === 0 ? 0 : undefined });
|
|
2031
|
+
} else {
|
|
2032
|
+
body.replaceChildren(el("div", "cn-note", t("paywall.checking")));
|
|
2033
|
+
if (!dialog.open) dialog.showModal();
|
|
2034
|
+
}
|
|
1748
2035
|
void check();
|
|
1749
2036
|
if (paywallTimer) clearInterval(paywallTimer);
|
|
1750
2037
|
paywallTimer = setInterval(() => void check(), PAYWALL_POLL_MS);
|
|
@@ -1891,5 +2178,261 @@ document.getElementById("nav-models").addEventListener("click", () => {
|
|
|
1891
2178
|
panelBody.replaceChildren(panelLoading());
|
|
1892
2179
|
void showModels();
|
|
1893
2180
|
});
|
|
2181
|
+
|
|
2182
|
+
/**
|
|
2183
|
+
* The feedback dialog: a kind, a message, screenshots or recordings, and Send.
|
|
2184
|
+
*
|
|
2185
|
+
* Sent through this app's server, which holds the account's token and adds
|
|
2186
|
+
* the platform; the page adds the app version, which only the desktop shell
|
|
2187
|
+
* knows. Files are picked, pasted or dropped, and uploaded one at a time when
|
|
2188
|
+
* Send is pressed — not when attached, so a file removed again was never sent
|
|
2189
|
+
* anywhere. The result is said inside the dialog rather than as a notice in the
|
|
2190
|
+
* transcript, which is not where anybody sending feedback is looking.
|
|
2191
|
+
*/
|
|
2192
|
+
const FEEDBACK_KINDS = ["bug", "idea", "other"];
|
|
2193
|
+
const FEEDBACK_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "video/mp4", "video/webm", "video/quicktime"];
|
|
2194
|
+
const FEEDBACK_MAX_FILES = 5;
|
|
2195
|
+
const FEEDBACK_MAX_BYTES = 50 * 1024 * 1024;
|
|
2196
|
+
let feedbackKind = "bug";
|
|
2197
|
+
/** What is attached: the file, a preview URL, and once uploaded, what the server answered. */
|
|
2198
|
+
let feedbackFiles = [];
|
|
2199
|
+
let feedbackWired = false;
|
|
2200
|
+
|
|
2201
|
+
function feedbackSize(bytes) {
|
|
2202
|
+
return bytes >= 1048576 ? (bytes / 1048576).toFixed(1) + " MB" : Math.max(1, Math.round(bytes / 1024)) + " KB";
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
function feedbackSay(text, error) {
|
|
2206
|
+
const status = document.getElementById("fb-status");
|
|
2207
|
+
status.textContent = text;
|
|
2208
|
+
status.style.color = error ? "var(--err)" : "";
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
/** Take files from the picker, a paste or a drop, saying why any are refused. */
|
|
2212
|
+
function feedbackAdd(files) {
|
|
2213
|
+
for (const file of files) {
|
|
2214
|
+
if (feedbackFiles.length >= FEEDBACK_MAX_FILES) {
|
|
2215
|
+
feedbackSay(t("feedback.tooMany"), true);
|
|
2216
|
+
break;
|
|
2217
|
+
}
|
|
2218
|
+
if (!FEEDBACK_TYPES.includes(file.type)) {
|
|
2219
|
+
feedbackSay(t("feedback.badType", { name: file.name || "file" }), true);
|
|
2220
|
+
continue;
|
|
2221
|
+
}
|
|
2222
|
+
if (file.size > FEEDBACK_MAX_BYTES) {
|
|
2223
|
+
feedbackSay(t("feedback.tooBig", { name: file.name || "file" }), true);
|
|
2224
|
+
continue;
|
|
2225
|
+
}
|
|
2226
|
+
// A pasted screenshot arrives as "image.png" every time; give it a name
|
|
2227
|
+
// that tells two of them apart on the internal page.
|
|
2228
|
+
const name = file.name && file.name !== "image.png" ? file.name : "screenshot-" + (feedbackFiles.length + 1) + ".png";
|
|
2229
|
+
feedbackFiles.push({ file, name, preview: URL.createObjectURL(file), uploaded: null, state: null });
|
|
2230
|
+
}
|
|
2231
|
+
feedbackPaintFiles();
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
function feedbackRemove(entry) {
|
|
2235
|
+
URL.revokeObjectURL(entry.preview);
|
|
2236
|
+
feedbackFiles = feedbackFiles.filter((other) => other !== entry);
|
|
2237
|
+
feedbackPaintFiles();
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
function feedbackClearFiles() {
|
|
2241
|
+
for (const entry of feedbackFiles) URL.revokeObjectURL(entry.preview);
|
|
2242
|
+
feedbackFiles = [];
|
|
2243
|
+
feedbackPaintFiles();
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
function feedbackPaintFiles() {
|
|
2247
|
+
const list = document.getElementById("fb-files");
|
|
2248
|
+
const tiles = feedbackFiles.map((entry) => {
|
|
2249
|
+
const video = entry.file.type.startsWith("video/");
|
|
2250
|
+
const tile = el("div", "fb-tile" + (video ? " is-video" : "") + (entry.state ? " is-" + entry.state : ""));
|
|
2251
|
+
tile.title = entry.name + " \u00b7 " + feedbackSize(entry.file.size);
|
|
2252
|
+
const thumb = el(video ? "video" : "img", "fb-thumb");
|
|
2253
|
+
thumb.src = entry.preview;
|
|
2254
|
+
if (video) {
|
|
2255
|
+
thumb.muted = true;
|
|
2256
|
+
thumb.preload = "metadata";
|
|
2257
|
+
tile.appendChild(thumb);
|
|
2258
|
+
tile.appendChild(el("span", "fb-play"));
|
|
2259
|
+
} else {
|
|
2260
|
+
thumb.alt = entry.name;
|
|
2261
|
+
tile.appendChild(thumb);
|
|
2262
|
+
}
|
|
2263
|
+
// Only while a send is in flight: one tile uploading, the ones before it done.
|
|
2264
|
+
if (entry.state === "uploading") tile.appendChild(el("span", "fb-spinner"));
|
|
2265
|
+
if (entry.state === "done") tile.appendChild(el("span", "fb-done", "\u2713"));
|
|
2266
|
+
const remove = el("button", "fb-remove", "\u00d7");
|
|
2267
|
+
remove.type = "button";
|
|
2268
|
+
remove.title = t("feedback.remove");
|
|
2269
|
+
remove.setAttribute("aria-label", t("feedback.remove") + " " + entry.name);
|
|
2270
|
+
remove.disabled = Boolean(entry.state);
|
|
2271
|
+
remove.addEventListener("click", () => feedbackRemove(entry));
|
|
2272
|
+
tile.appendChild(remove);
|
|
2273
|
+
return tile;
|
|
2274
|
+
});
|
|
2275
|
+
if (feedbackFiles.length < FEEDBACK_MAX_FILES) {
|
|
2276
|
+
const add = el("button", "fb-tile fb-add");
|
|
2277
|
+
add.type = "button";
|
|
2278
|
+
add.setAttribute("aria-label", t("feedback.attach"));
|
|
2279
|
+
add.title = t("feedback.attach");
|
|
2280
|
+
add.append(svgIcon("image", "fb-add-icon"), el("span", "fb-add-label", t("feedback.add")));
|
|
2281
|
+
add.addEventListener("click", () => document.getElementById("fb-file").click());
|
|
2282
|
+
tiles.push(add);
|
|
2283
|
+
}
|
|
2284
|
+
// What can go here and how much, beside the tiles rather than on a line of
|
|
2285
|
+
// its own: before anything is attached it says what to attach and how;
|
|
2286
|
+
// after, how many of the five are used.
|
|
2287
|
+
const caption = el("div", "fb-caption");
|
|
2288
|
+
caption.appendChild(el("span", "fb-caption-title", t("feedback.mediaTitle")));
|
|
2289
|
+
caption.appendChild(
|
|
2290
|
+
el(
|
|
2291
|
+
"span",
|
|
2292
|
+
"fb-caption-sub",
|
|
2293
|
+
feedbackFiles.length
|
|
2294
|
+
? t("feedback.mediaCount", { n: feedbackFiles.length, max: FEEDBACK_MAX_FILES })
|
|
2295
|
+
: t("feedback.mediaHint"),
|
|
2296
|
+
),
|
|
2297
|
+
);
|
|
2298
|
+
tiles.push(caption);
|
|
2299
|
+
list.replaceChildren(...tiles);
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
/** Upload one file through this server; the answer is what the row stores. */
|
|
2303
|
+
async function feedbackUpload(entry) {
|
|
2304
|
+
if (entry.uploaded) return entry.uploaded;
|
|
2305
|
+
const response = await fetch(q("/feedback/upload"), {
|
|
2306
|
+
method: "POST",
|
|
2307
|
+
headers: { "content-type": entry.file.type, "x-filename": encodeURIComponent(entry.name) },
|
|
2308
|
+
body: entry.file,
|
|
2309
|
+
signal: AbortSignal.timeout(15 * 60 * 1000),
|
|
2310
|
+
}).catch((err) => ({ ok: false, json: async () => ({ error: String(err.message || err) }) }));
|
|
2311
|
+
const body = await response.json().catch(() => ({}));
|
|
2312
|
+
if (!response.ok || !body.path) throw new Error(body.error || "upload failed");
|
|
2313
|
+
// Kept, so a retry after a later file fails does not send this one twice.
|
|
2314
|
+
entry.uploaded = { path: body.path, name: body.name || entry.name, type: body.type || entry.file.type, size: body.size || entry.file.size };
|
|
2315
|
+
return entry.uploaded;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
/** The listeners that outlive one opening: the picker, paste and drop. */
|
|
2319
|
+
function wireFeedback(dialog) {
|
|
2320
|
+
if (feedbackWired) return;
|
|
2321
|
+
feedbackWired = true;
|
|
2322
|
+
const picker = document.getElementById("fb-file");
|
|
2323
|
+
picker.addEventListener("change", () => {
|
|
2324
|
+
feedbackAdd([...picker.files]);
|
|
2325
|
+
picker.value = "";
|
|
2326
|
+
});
|
|
2327
|
+
// A screenshot on the clipboard is the most common attachment of all.
|
|
2328
|
+
document.getElementById("fb-message").addEventListener("paste", (e) => {
|
|
2329
|
+
const files = [...(e.clipboardData ? e.clipboardData.files : [])];
|
|
2330
|
+
if (!files.length) return;
|
|
2331
|
+
e.preventDefault();
|
|
2332
|
+
feedbackAdd(files);
|
|
2333
|
+
});
|
|
2334
|
+
dialog.addEventListener("dragover", (e) => {
|
|
2335
|
+
if (!e.dataTransfer || ![...e.dataTransfer.types].includes("Files")) return;
|
|
2336
|
+
e.preventDefault();
|
|
2337
|
+
dialog.classList.add("fb-dropping");
|
|
2338
|
+
});
|
|
2339
|
+
dialog.addEventListener("dragleave", (e) => {
|
|
2340
|
+
if (e.target === dialog) dialog.classList.remove("fb-dropping");
|
|
2341
|
+
});
|
|
2342
|
+
dialog.addEventListener("drop", (e) => {
|
|
2343
|
+
dialog.classList.remove("fb-dropping");
|
|
2344
|
+
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
|
2345
|
+
e.preventDefault();
|
|
2346
|
+
feedbackAdd([...e.dataTransfer.files]);
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
function openFeedback() {
|
|
2351
|
+
const dialog = document.getElementById("feedback-dialog");
|
|
2352
|
+
const kinds = document.getElementById("fb-kinds");
|
|
2353
|
+
const message = document.getElementById("fb-message");
|
|
2354
|
+
const send = document.getElementById("fb-send");
|
|
2355
|
+
const cancel = document.getElementById("fb-cancel");
|
|
2356
|
+
wireFeedback(dialog);
|
|
2357
|
+
|
|
2358
|
+
const paintKinds = () => {
|
|
2359
|
+
kinds.replaceChildren(
|
|
2360
|
+
...FEEDBACK_KINDS.map((kind) => {
|
|
2361
|
+
const chip = el("button", "fb-kind" + (kind === feedbackKind ? " on" : ""), t("feedback.kind." + kind));
|
|
2362
|
+
chip.type = "button";
|
|
2363
|
+
chip.setAttribute("role", "radio");
|
|
2364
|
+
chip.setAttribute("aria-checked", String(kind === feedbackKind));
|
|
2365
|
+
chip.addEventListener("click", () => {
|
|
2366
|
+
feedbackKind = kind;
|
|
2367
|
+
paintKinds();
|
|
2368
|
+
});
|
|
2369
|
+
return chip;
|
|
2370
|
+
}),
|
|
2371
|
+
);
|
|
2372
|
+
};
|
|
2373
|
+
|
|
2374
|
+
paintKinds();
|
|
2375
|
+
feedbackPaintFiles();
|
|
2376
|
+
message.placeholder = t("feedback.placeholder");
|
|
2377
|
+
feedbackSay("");
|
|
2378
|
+
send.disabled = false;
|
|
2379
|
+
send.textContent = t("feedback.send");
|
|
2380
|
+
cancel.onclick = () => dialog.close();
|
|
2381
|
+
// A draft — text and files — survives closing the dialog by accident; it is
|
|
2382
|
+
// cleared only once sent.
|
|
2383
|
+
send.onclick = async () => {
|
|
2384
|
+
const text = message.value.trim();
|
|
2385
|
+
if (!text) {
|
|
2386
|
+
feedbackSay(t("feedback.empty"), true);
|
|
2387
|
+
return void message.focus();
|
|
2388
|
+
}
|
|
2389
|
+
send.disabled = true;
|
|
2390
|
+
const attachments = [];
|
|
2391
|
+
try {
|
|
2392
|
+
for (let i = 0; i < feedbackFiles.length; i++) {
|
|
2393
|
+
const entry = feedbackFiles[i];
|
|
2394
|
+
send.textContent = t("feedback.uploading", { n: i + 1, total: feedbackFiles.length });
|
|
2395
|
+
entry.state = entry.uploaded ? "done" : "uploading";
|
|
2396
|
+
feedbackPaintFiles();
|
|
2397
|
+
attachments.push(await feedbackUpload(entry));
|
|
2398
|
+
entry.state = "done";
|
|
2399
|
+
feedbackPaintFiles();
|
|
2400
|
+
}
|
|
2401
|
+
} catch (err) {
|
|
2402
|
+
// Back to editable; files already uploaded keep their answer, so a
|
|
2403
|
+
// retry only sends the rest.
|
|
2404
|
+
for (const entry of feedbackFiles) entry.state = null;
|
|
2405
|
+
feedbackPaintFiles();
|
|
2406
|
+
send.disabled = false;
|
|
2407
|
+
send.textContent = t("feedback.send");
|
|
2408
|
+
return void feedbackSay(String(err.message || err), true);
|
|
2409
|
+
}
|
|
2410
|
+
send.textContent = t("feedback.sending");
|
|
2411
|
+
const bridge = window.oharness;
|
|
2412
|
+
const result = await api("/feedback", {
|
|
2413
|
+
kind: feedbackKind,
|
|
2414
|
+
message: text,
|
|
2415
|
+
source: bridge && bridge.desktop ? "desktop" : "web",
|
|
2416
|
+
appVersion: (bridge && bridge.app && bridge.app.version) || undefined,
|
|
2417
|
+
attachments,
|
|
2418
|
+
});
|
|
2419
|
+
send.textContent = t("feedback.send");
|
|
2420
|
+
if (!result || result.error) {
|
|
2421
|
+
for (const entry of feedbackFiles) entry.state = null;
|
|
2422
|
+
feedbackPaintFiles();
|
|
2423
|
+
send.disabled = false;
|
|
2424
|
+
return void feedbackSay((result && result.error) || "could not send", true);
|
|
2425
|
+
}
|
|
2426
|
+
track("feedback_sent", { kind: feedbackKind, attachments: attachments.length });
|
|
2427
|
+
message.value = "";
|
|
2428
|
+
feedbackClearFiles();
|
|
2429
|
+
feedbackSay(t("feedback.sent"));
|
|
2430
|
+
setTimeout(() => {
|
|
2431
|
+
if (dialog.open) dialog.close();
|
|
2432
|
+
}, 1400);
|
|
2433
|
+
};
|
|
2434
|
+
if (!dialog.open) dialog.showModal();
|
|
2435
|
+
message.focus();
|
|
2436
|
+
}
|
|
1894
2437
|
`;
|
|
1895
2438
|
//# sourceMappingURL=account.js.map
|