Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,9 +450,10 @@ tests/
language-catalog.test.ts # one RTL list, picker names, bundled Vazirmatn
own-language.test.ts # translate request with / without a server profile; language store
popup-languages.test.ts # popup pill, language list, switch, plan limit, due line
save-language.test.ts # save window language row, Keep / Save to, Undo, dark phase
save-language.test.ts # save window language row, saved state, Keep / Save to, Undo, dark phase
language-lookups.test.ts # saved-phrase lookup, bundle suggestion, last-used bundles per language
language-analytics.test.ts # learning_language and target_language super properties
tab-broadcast.test.ts # tab broadcast skips tabs without our content scripts quietly
e2e/
extension-fixture.ts # chromium.launchPersistentContext + extension load
server.mjs # static fixtures HTTP server
Expand Down
26 changes: 24 additions & 2 deletions src/common/helper/massage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,19 @@ export async function sendMessage<T extends BaseMessage | LoginStatusResponse>(
}

/**
* Send message to all tabs
* True for the error Chrome gives when a tab has no listener: tabs without our
* content scripts (chrome:// pages, the Web Store, tabs opened before the
* extension was installed or reloaded). Expected for a broadcast.
*/
export function isNoReceiverError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error ?? "");
return /Receiving end does not exist/i.test(message);
}

/**
* Send message to all tabs. Fire-and-forget: a tab without our content
* scripts is skipped quietly (E-03: the popup used to log an uncaught
* "Receiving end does not exist" for each such tab after a language switch).
* @param message
* @returns
*/
Expand All @@ -48,7 +60,17 @@ export function sendMessageToTabs<T extends BaseMessage | LoginStatusResponse>(

chrome.tabs.query({}, (tabs) => {
for (const tab of tabs) {
tab?.id && chrome.tabs.sendMessage(tab.id, message);
if (!tab?.id) continue;
// Cast: @types/chrome still types tabs.sendMessage as void; in MV3 the
// no-callback overload returns a Promise.
const sent = chrome.tabs.sendMessage(tab.id, message) as unknown as
| Promise<unknown>
| undefined;
sent?.catch?.((error: unknown) => {
if (!isNoReceiverError(error)) {
console.warn("Could not send a message to a tab:", error);
}
});
}
});
}
56 changes: 33 additions & 23 deletions src/console-crane/components/SaveWordSectionV2.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<template>
<div class="my-2 bg-white dark:bg-gray-900 rounded-xl">
<!-- Result of the two choices for a language not added yet (design X7,
X8): in place, with Undo, and the learner stays on the video. -->
<!-- Result of a save (design X6), or of the two choices for a language
not added yet (X7, X8, with Undo): in place, and the learner stays on
the video. -->
<div
v-if="outcomeBox"
role="status"
Expand Down Expand Up @@ -30,7 +31,7 @@
<button v-else-if="offerPractice && outcomeBox.phraseId" type="button" data-testid="practise-now"
class="px-4 py-2 rounded-full bg-white dark:bg-white/[0.08] border border-gray-200 dark:border-white/[0.12] text-gray-900 dark:text-white text-sm font-bold"
@click="$emit('practice', { phraseId: outcomeBox.phraseId, language: outcomeBox.language })">Practise it now</button>
<button type="button" data-testid="undo"
<button v-if="outcomeCopy.canUndo" type="button" data-testid="undo"
class="px-3 py-2 rounded-full text-gray-600 dark:text-gray-300 hover:bg-white/60 dark:hover:bg-white/[0.06] text-sm font-bold disabled:opacity-50"
:disabled="isUndoing" @click="undo">Undo</button>
</span>
Expand Down Expand Up @@ -58,10 +59,6 @@
:busy="isSaving || isKeeping" @change="pickerOpen = true" @save-active="saveToActive" @keep="keepForDetected"
@add="openAddLanguage(row.detected)" />

<p v-if="savedLine" role="status" data-testid="saved-line"
class="mt-0 mb-3 text-sm font-bold text-emerald-700 dark:text-emerald-300 flex items-center gap-1.5">
<i class="i-solar-check-circle-bold" />{{ savedLine }}
</p>
<p v-if="actionError" role="alert" class="mt-0 mb-3 text-sm font-semibold text-red-600 dark:text-red-400">
{{ actionError }}
</p>
Expand All @@ -70,7 +67,7 @@
between the two buttons of a language not added (E-06). -->
<template v-if="!row || row.kind !== 'missing'">
<FreemiumLimitCounter v-if="showFreemiumCounter" :used="usedCount" :total="totalCount" :isAtLimit="!!isAtLimit"
:isDisabled="!!(isSaving || isAtLimit)" @action="savePhrase" @upgrade="handleUpgrade" class="mb-4">
:isDisabled="!!(isSaving || isAtLimit)" @action="savePhrase()" @upgrade="handleUpgrade" class="mb-4">
<InputGroup>
<SelectPhraseBundleV2 class="flex-1" ref="selectBundleRef" v-model:selected-bundles="selectedBundles" :language="targetLanguage"
:suggested-name="useSuggested ? suggestedName : ''" @update:suggested-name="suggestedName = $event" />
Expand All @@ -88,7 +85,7 @@
<div class="flex w-full">
<SelectPhraseBundleV2 ref="selectBundleRef" v-model:selected-bundles="selectedBundles" :language="targetLanguage"
:suggested-name="useSuggested ? suggestedName : ''" @update:suggested-name="suggestedName = $event" />
<Button :label="saveLabel" size="lg" @click="savePhrase" :disabled="!canSave || isSaving" :is-loading="isSaving"
<Button :label="saveLabel" size="lg" @click="savePhrase()" :disabled="!canSave || isSaving" :is-loading="isSaving"
:class="plainSaveActive ? ACTIVE_SAVE_CLASS : DISABLED_SAVE_CLASS">
<template #icon>
<i class="mr-4 i-ep-collection" />
Expand Down Expand Up @@ -197,10 +194,15 @@ const languages = useLanguageStore();
const override = ref<string | null>(null);
const pickerOpen = ref(false);
const outcome = ref<SaveOutcome | null>(null);
const savedLine = ref("");
const actionError = ref("");
const isKeeping = ref(false);
const isUndoing = ref(false);
/**
* The learner chose "Save to <active>" for a language not added yet, and no
* bundle was ready, so the picker shows: the Save that follows is still that
* choice (X8, with Undo back to the two choices).
*/
const choseInstead = ref(false);

const row = computed(() =>
languages.isMultilingual && languages.state
Expand All @@ -215,9 +217,7 @@ const row = computed(() =>
/** Where Save writes; null in the dark phase, and while the learner must choose. */
const targetLanguage = computed<string | null>(() => row.value?.target ?? null);
const canChange = computed(() => activeLanguages(languages.state).length > 1);
const outcomeBox = computed(() =>
outcome.value && outcome.value.kind !== "saved" ? outcome.value : null
);
const outcomeBox = computed(() => outcome.value);
const outcomeCopy = computed(() =>
outcomeBox.value
? saveOutcomeCopy(outcomeBox.value, props.phrase, languages.caps)
Expand Down Expand Up @@ -321,9 +321,9 @@ watch(
() => {
override.value = null;
outcome.value = null;
savedLine.value = "";
actionError.value = "";
pickerOpen.value = false;
choseInstead.value = false;
},
{ immediate: true }
);
Expand Down Expand Up @@ -519,10 +519,15 @@ function bundleTitles(ids: string[], suggested: string): string[] {

/**
* @param kind "savedInstead" when the learner chose "Save to <active>" for a
* language not added yet: the result shows in place with Undo (X8).
* language not added yet: the result shows in place with Undo (X8). By
* default it is that choice when the learner made it and then picked a
* bundle, else a normal save (X6).
*/
async function savePhrase(kind: "saved" | "savedInstead" = "saved") {
async function savePhrase(kind?: "saved" | "savedInstead") {
if (!canSave.value || isSaving.value) return;
if (kind !== "saved" && kind !== "savedInstead") {
kind = choseInstead.value ? "savedInstead" : "saved";
}
isSaving.value = true;
actionError.value = "";

Expand Down Expand Up @@ -577,7 +582,9 @@ async function savePhrase(kind: "saved" | "savedInstead" = "saved") {
await loadExistingBundles();
await profileStore.fetchSubscription();

// "Saved to Dark S1 · German" (E-05, design X6 / X8).
// "Saved to Dark S1 · German" with Back to my video and Practise it now
// (E-05, design X6 / X8). Without language profiles the window stays as
// before: the bundle picker with the saved bundles.
const savedLanguage = (result as any)?.language || language;
if (savedLanguage) {
const titles = bundleTitles(toAdd, suggestedTitle);
Expand All @@ -588,12 +595,14 @@ async function savePhrase(kind: "saved" | "savedInstead" = "saved") {
bundleIds: toAdd,
phraseId: result?._id,
};
if (kind === "savedInstead") outcome.value = done;
else savedLine.value = saveOutcomeCopy(done, props.phrase, languages.caps).title;
outcome.value = done;
}

if (existedPhrase.value) {
emit("saved", existedPhrase.value);
// The re-synced document, else what createPhrase returned (a lookup that
// has not caught up yet must not hide the save from the page).
const savedDoc = existedPhrase.value ?? result;
if (savedDoc) {
emit("saved", savedDoc);
}

if (selectBundleRef.value?.closeDropdown) {
Expand All @@ -612,7 +621,7 @@ async function savePhrase(kind: "saved" | "savedInstead" = "saved") {
/** Change: this phrase goes to another of the learner's languages (X5). */
function pickLanguage(code: string) {
pickerOpen.value = false;
savedLine.value = "";
choseInstead.value = false;
override.value = code;
analytic.track("save_language_changed", {
detected: row.value?.detected,
Expand All @@ -629,6 +638,7 @@ async function saveToActive() {
if (!row.value) return;
const active = row.value.active;
override.value = active;
choseInstead.value = true;
analytic.track("save_language_missing_choice", {
choice: "save_to_active",
detected: row.value.detected,
Expand Down Expand Up @@ -690,8 +700,8 @@ async function undo() {
}
analytic.track("save_language_undo", { kind: done.kind, language: done.language });
outcome.value = null;
savedLine.value = "";
override.value = null;
choseInstead.value = false;
} catch (error) {
console.error("Error undoing:", error);
actionError.value = "We could not undo that. Please try again.";
Expand Down
7 changes: 4 additions & 3 deletions src/console-crane/components/SelectPhraseBundleV2.vue
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import { PhraseBundleType } from "../../common/types/phrase.type";
import { analytic } from "../../plugins/mixpanel";
import { log } from "../../common/helper/log";
import { languageFilter } from "../../common/language/state";
import { dispatchInternalEscape } from "../escape";

const props = defineProps<{
selectedBundles: string[];
Expand Down Expand Up @@ -325,9 +326,9 @@ function closeDropdown() {
const trigger = rootRef.value?.querySelector<HTMLButtonElement>(
'button[aria-haspopup="true"]'
);
trigger?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
);
// Marked as ours, so the ConsoleCrane window's Esc handler does not read it
// as "close the window" (see escape.ts).
if (trigger) dispatchInternalEscape(trigger);
}

/**
Expand Down
33 changes: 33 additions & 0 deletions src/console-crane/escape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,31 @@
* event is let through so the field's own Esc handler runs (the advice chat's
* edit box uses it as "cancel edit"), then the field is blurred. A stray Esc
* while typing shouldn't throw the whole card away.
*
* Esc while a dropdown (the bundle list) is open closes only the dropdown.
*/

/**
* Escape keydowns we dispatch ourselves. pilotui's Select has no close method,
* so SelectPhraseBundleV2 closes its dropdown by sending the Escape key the
* Select trigger already handles. That synthetic event also passes our
* window capture listener, which used to read it as "close the window": the
* save window closed right after "Save to German" (closeDropdown runs after a
* save) and on any click inside the window while the bundle list was open
* (the outside-click close). Events in this set are let through untouched.
*/
const internalEscapes = new WeakSet<Event>();

/** Send an Escape keydown to `target` that the console's Esc handler ignores. */
export function dispatchInternalEscape(target: EventTarget): void {
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
internalEscapes.add(event);
target.dispatchEvent(event);
}

/** A pilotui Select trigger whose dropdown is open, inside the modal. */
const OPEN_DROPDOWN_SELECTOR = '[aria-haspopup="true"][aria-expanded="true"]';

export interface ConsoleEscapeTarget {
isOnMainPage: boolean;
canGoBack: boolean;
Expand All @@ -36,6 +60,8 @@ export function handleConsoleEscape(
modalRoot: Element | null
): boolean {
if (event.key !== "Escape" || event.isComposing) return false;
// Our own dropdown close (see dispatchInternalEscape): not for the window.
if (internalEscapes.has(event)) return false;

const active = event.target;
if (isEditable(active) && modalRoot?.contains(active)) {
Expand All @@ -47,6 +73,13 @@ export function handleConsoleEscape(
event.preventDefault();
event.stopPropagation();

// An open dropdown (the bundle list) closes first; the window stays.
const openDropdown = modalRoot?.querySelector(OPEN_DROPDOWN_SELECTOR);
if (openDropdown) {
dispatchInternalEscape(openDropdown);
return true;
}

if (!target.isOnMainPage && target.canGoBack) target.goBack();
else target.close();
return true;
Expand Down
42 changes: 41 additions & 1 deletion tests/console-escape.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { handleConsoleEscape } from "../src/console-crane/escape";
import { handleConsoleEscape, dispatchInternalEscape } from "../src/console-crane/escape";

function esc(target?: EventTarget) {
const e = new KeyboardEvent("keydown", { key: "Escape", cancelable: true, bubbles: true });
Expand Down Expand Up @@ -56,6 +56,46 @@ describe("console-crane Esc", () => {
expect(blur).toHaveBeenCalled();
});

// E-05 regression: the bundle list closes itself with a synthetic Escape on
// its trigger (pilotui has no close method). The window must not close on
// it, after a save or on a click inside the window.
it("lets our own dropdown-close Escape through without closing the window", () => {
const trigger = document.createElement("button");
root.appendChild(trigger);
const seen: KeyboardEvent[] = [];
const onWindow = (e: Event) => handleConsoleEscape(e as KeyboardEvent, nav, root);
window.addEventListener("keydown", onWindow, true);
trigger.addEventListener("keydown", (e) => seen.push(e as KeyboardEvent));
try {
dispatchInternalEscape(trigger);
} finally {
window.removeEventListener("keydown", onWindow, true);
}
expect(nav.close).not.toHaveBeenCalled();
expect(nav.goBack).not.toHaveBeenCalled();
// It still reaches the trigger, so the dropdown closes.
expect(seen.map((e) => e.key)).toEqual(["Escape"]);
});

it("with the bundle list open, Esc closes the list only", () => {
const trigger = document.createElement("button");
trigger.setAttribute("aria-haspopup", "true");
trigger.setAttribute("aria-expanded", "true");
root.appendChild(trigger);
const seen: string[] = [];
trigger.addEventListener("keydown", (e) => seen.push((e as KeyboardEvent).key));
const e = esc();
expect(handleConsoleEscape(e, nav, root)).toBe(true);
expect(e.defaultPrevented).toBe(true);
expect(nav.close).not.toHaveBeenCalled();
expect(seen).toEqual(["Escape"]);

// Closed list: Esc closes the window as before.
trigger.setAttribute("aria-expanded", "false");
handleConsoleEscape(esc(), nav, root);
expect(nav.close).toHaveBeenCalled();
});

it("ignores other keys", () => {
const e = new KeyboardEvent("keydown", { key: "Enter", cancelable: true });
expect(handleConsoleEscape(e, nav, root)).toBe(false);
Expand Down
Loading
Loading