diff --git a/packages/extension/.env.development b/packages/extension/.env.development new file mode 100644 index 00000000..a1f065dd --- /dev/null +++ b/packages/extension/.env.development @@ -0,0 +1 @@ +VITE_NEW_HUB_URL=http://localhost:3000 diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index 2cf31720..8d88d6d1 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -740,6 +740,9 @@ "Option_copy_tooltip": { "message": "Duplizieren" }, + "Option_shareButton_tooltip": { + "message": "Mit Hub teilen" + }, "Option_remove_title": { "message": "Dies löschen?" }, diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index 21bfcfba..2245b928 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -741,6 +741,10 @@ "Option_copy_tooltip": { "message": "Duplicate" }, + "Option_shareButton_tooltip": { + "message": "Share to Hub", + "description": "Tooltip for the share button in the command list. Opens the new Selection Command Hub in a new tab with the command pre-filled." + }, "Option_remove_title": { "message": "Delete this?" }, diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index 7b6abede..40fa7b2c 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -740,6 +740,9 @@ "Option_copy_tooltip": { "message": "Duplicar" }, + "Option_shareButton_tooltip": { + "message": "Compartir en Hub" + }, "Option_remove_title": { "message": "¿Eliminar esto?" }, diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index 7798b134..8cb7f21c 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -740,6 +740,9 @@ "Option_copy_tooltip": { "message": "Dupliquer" }, + "Option_shareButton_tooltip": { + "message": "Partager sur Hub" + }, "Option_remove_title": { "message": "Supprimer ceci ?" }, diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index eadb2428..c12baec9 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -740,6 +740,9 @@ "Option_copy_tooltip": { "message": "डुप्लिकेट" }, + "Option_shareButton_tooltip": { + "message": "Hub पर साझा करें" + }, "Option_remove_title": { "message": "क्या आप वाकई इसे हटाना चाहते हैं?" }, diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index ac43239b..0fc81d1c 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -740,6 +740,9 @@ "Option_copy_tooltip": { "message": "Duplikat" }, + "Option_shareButton_tooltip": { + "message": "Bagikan ke Hub" + }, "Option_remove_title": { "message": "Hapus ini?" }, diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index c69923c7..4a81f221 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -740,6 +740,9 @@ "Option_copy_tooltip": { "message": "Duplica" }, + "Option_shareButton_tooltip": { + "message": "Condividi su Hub" + }, "Option_remove_title": { "message": "Sei sicuro di voler eliminare questo?" }, diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index 3123ddb8..0a85400d 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -740,6 +740,9 @@ "Option_copy_tooltip": { "message": "コピー" }, + "Option_shareButton_tooltip": { + "message": "Hubに共有" + }, "Option_remove_title": { "message": "削除しますか?" }, diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index 6c3a5062..ff0d2e5d 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -740,6 +740,9 @@ "Option_copy_tooltip": { "message": "복제" }, + "Option_shareButton_tooltip": { + "message": "Hub에 공유" + }, "Option_remove_title": { "message": "이것을 삭제하시겠습니까?" }, diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index 10638e75..831d3d13 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -740,6 +740,9 @@ "Option_copy_tooltip": { "message": "Duplikat" }, + "Option_shareButton_tooltip": { + "message": "Kongsi ke Hub" + }, "Option_remove_title": { "message": "Padam ini?" }, diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index 49d7b97f..e5767ffc 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -740,6 +740,9 @@ "Option_copy_tooltip": { "message": "Duplicar" }, + "Option_shareButton_tooltip": { + "message": "Compartilhar no Hub" + }, "Option_remove_title": { "message": "Excluir isto?" }, diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index 69e1c692..a02df32b 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -740,6 +740,9 @@ "Option_copy_tooltip": { "message": "Duplicar" }, + "Option_shareButton_tooltip": { + "message": "Partilhar no Hub" + }, "Option_remove_title": { "message": "Eliminar isto?" }, diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index a88819be..caac6a56 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -740,6 +740,9 @@ "Option_copy_tooltip": { "message": "Дублировать" }, + "Option_shareButton_tooltip": { + "message": "Поделиться в Hub" + }, "Option_remove_title": { "message": "Вы уверены, что хотите удалить это?" }, diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index 1f7abf17..829d0086 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -740,6 +740,9 @@ "Option_copy_tooltip": { "message": "复制" }, + "Option_shareButton_tooltip": { + "message": "分享到 Hub" + }, "Option_remove_title": { "message": "您确定要删除吗?" }, diff --git a/packages/extension/src/components/option/ShareButton.tsx b/packages/extension/src/components/option/ShareButton.tsx new file mode 100644 index 00000000..3865888e --- /dev/null +++ b/packages/extension/src/components/option/ShareButton.tsx @@ -0,0 +1,64 @@ +import { useState, useRef } from "react" +import { Share } from "lucide-react" +import { Tooltip } from "@/components/Tooltip" +import { cn } from "@/lib/utils" +import { t } from "@/services/i18n" +import { shareCommandToHub } from "@/services/hubShare" +import { NEW_HUB_SHAREABLE_OPEN_MODES, COMMAND_SOURCE_TYPE } from "@/const" +import type { SelectionCommand } from "@/types" + +const VALID_SOURCE_TYPES = new Set([ + COMMAND_SOURCE_TYPE.SELF_CREATED, + COMMAND_SOURCE_TYPE.SELF_UPDATED, + COMMAND_SOURCE_TYPE.UNKNOWN, +]) + +type Props = { + command: SelectionCommand +} + +export const ShareButton = ({ command }: Props) => { + const buttonRef = useRef(null) + const [status, setStatus] = useState<"idle" | "sent" | "error">("idle") + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation() + const ok = shareCommandToHub(command) + setStatus(ok ? "sent" : "error") + setTimeout(() => setStatus("idle"), 2000) + } + + if ( + !NEW_HUB_SHAREABLE_OPEN_MODES.has(command.openMode) || + !VALID_SOURCE_TYPES.has(command.sourceType ?? COMMAND_SOURCE_TYPE.UNKNOWN) + ) { + return null + } + + return ( + <> + + + + ) +} diff --git a/packages/extension/src/components/option/editor/CommandEditDialog.tsx b/packages/extension/src/components/option/editor/CommandEditDialog.tsx index cd92dcee..59d9e239 100644 --- a/packages/extension/src/components/option/editor/CommandEditDialog.tsx +++ b/packages/extension/src/components/option/editor/CommandEditDialog.tsx @@ -93,6 +93,7 @@ import { commandSchema, CommandSchemaType, isPageActionType, + isAiPromptType, } from "@/types/schema" import type { SelectionCommand, @@ -358,6 +359,17 @@ const CommandEditDialogInner = ({ defaultValue: "", }) + const pageActionOption = useWatch({ + control: form.control, + name: "pageActionOption", + }) + + const aiPromptPrompt = useWatch({ + control: form.control, + name: "aiPromptOption.prompt", + defaultValue: "", + }) + const iconUrlSrc = searchUrl || startUrl const openPageActionRecorder = async () => { @@ -450,6 +462,35 @@ const CommandEditDialogInner = ({ } }, []) + useEffect(() => { + if (!initialized) return + if (!isUpdate) return + if (command.sourceType === COMMAND_SOURCE_TYPE.SELF_CREATED) return + if (getValues("sourceType") === COMMAND_SOURCE_TYPE.SELF_UPDATED) return + + const changed = + (isSearchType(command) && searchUrl !== command.searchUrl) || + (isPageActionType(command) && + JSON.stringify(pageActionOption) !== + JSON.stringify(command.pageActionOption)) || + (isAiPromptType(command) && + aiPromptPrompt !== command.aiPromptOption.prompt) + + if (changed) { + setValue("sourceType", COMMAND_SOURCE_TYPE.SELF_UPDATED) + setValue("sourceId", COMMAND_SOURCE_ID.SELF_UPDATED) + } + }, [ + initialized, + isUpdate, + searchUrl, + pageActionOption, + aiPromptPrompt, + command, + getValues, + setValue, + ]) + return ( diff --git a/packages/extension/src/components/option/editor/CommandList.tsx b/packages/extension/src/components/option/editor/CommandList.tsx index 35175698..90528563 100644 --- a/packages/extension/src/components/option/editor/CommandList.tsx +++ b/packages/extension/src/components/option/editor/CommandList.tsx @@ -27,13 +27,7 @@ import { } from "@/types/schema" import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" -import { - SCREEN, - COMMAND_TYPE, - OPEN_MODE_TYPE_MAP, - COMMAND_SOURCE_TYPE, - COMMAND_SOURCE_ID, -} from "@/const" +import { SCREEN, COMMAND_TYPE, OPEN_MODE_TYPE_MAP } from "@/const" import type { Command, CommandFolder, SelectionCommand } from "@/types" // Imported services and hooks @@ -254,8 +248,6 @@ export const CommandList = ({ control }: CommandListProps) => { const cmd = commandArray.fields[index] cmd.id = crypto.randomUUID() cmd.title = title - cmd.sourceType = COMMAND_SOURCE_TYPE.SELF_CREATED - cmd.sourceId = COMMAND_SOURCE_ID.SELF_CREATED commandArray.insert(index + 1, cmd) } diff --git a/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx b/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx index a15931ac..a3d248c5 100644 --- a/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx +++ b/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx @@ -3,9 +3,10 @@ import { SortableItem } from "@/components/option/SortableItem" import { EditButton } from "@/components/option/EditButton" import { CopyButton } from "@/components/option/CopyButton" import { RemoveButton } from "@/components/option/RemoveButton" +import { ShareButton } from "@/components/option/ShareButton" import { MenuImage } from "@/components/menu/MenuImage" import type { FlattenNode } from "@/services/option/commandTree" -import type { CommandFolder } from "@/types" +import type { CommandFolder, SelectionCommand } from "@/types" import { isCommand, isFolder, @@ -68,6 +69,9 @@ export const CommandTreeRenderer: React.FC = ({
+ {isCommand(field.content) && ( + + )} {isPageActionCommand(field.content) && ( = new Set([ + OPEN_MODE.POPUP, + OPEN_MODE.TAB, + OPEN_MODE.WINDOW, + OPEN_MODE.BACKGROUND_TAB, + OPEN_MODE.SIDE_PANEL, + OPEN_MODE.PAGE_ACTION, + OPEN_MODE.AI_PROMPT, +]) + export const PAGE_ACTION_MAX = 12 // 10 actions + 1 start + 1 end export const PAGE_ACTION_TIMEOUT = 5000 @@ -340,6 +372,7 @@ export const COMMAND_USAGE = { export enum COMMAND_SOURCE_TYPE { DEFAULT = "default", SELF_CREATED = "selfCreated", + SELF_UPDATED = "selfUpdated", HUB_COMMUNITY = "hubCommunity", UNKNOWN = "unknown", } @@ -347,6 +380,7 @@ export enum COMMAND_SOURCE_TYPE { export const COMMAND_SOURCE_ID = { DEFAULT: "019db873-cc03-7484-86f1-2d349389ea2b", SELF_CREATED: "019db8a1-4021-7ae7-8a5d-474bf132e8ff", + SELF_UPDATED: "019de776-d3ea-76af-99fe-340ae9bab54d", } export const SHORTCUT_PLACEHOLDER = "_placeholder_" diff --git a/packages/extension/src/services/hubShare.test.ts b/packages/extension/src/services/hubShare.test.ts new file mode 100644 index 00000000..c21342e4 --- /dev/null +++ b/packages/extension/src/services/hubShare.test.ts @@ -0,0 +1,323 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { + getHubLocale, + toSubmitCommandInput, + shareCommandToHub, + _resetShareState, +} from "./hubShare" +import { OPEN_MODE, PAGE_ACTION_OPEN_MODE } from "@/const" +import type { SearchCommand, PageActionCommand, AiPromptCommand } from "@/types" + +// ---- Fixtures -------------------------------------------------------------- + +const baseCmd = { + id: "cmd-1", + title: "Test Command", + iconUrl: "https://example.com/icon.png", +} + +const makeSearchCmd = (overrides?: Partial): SearchCommand => ({ + ...baseCmd, + openMode: OPEN_MODE.POPUP, + searchUrl: "https://google.com/search?q=%s", + ...overrides, +}) + +const makePageActionCmd = ( + overrides?: Partial, +): PageActionCommand => ({ + ...baseCmd, + openMode: OPEN_MODE.PAGE_ACTION, + pageActionOption: { + startUrl: "https://example.com", + steps: [], + openMode: PAGE_ACTION_OPEN_MODE.TAB, + }, + ...overrides, +}) + +const makeAiPromptCmd = ( + overrides?: Partial, +): AiPromptCommand => ({ + ...baseCmd, + openMode: OPEN_MODE.AI_PROMPT, + aiPromptOption: { + serviceId: "gemini", + prompt: "Summarize: {{SelectedText}}", + openMode: OPEN_MODE.SIDE_PANEL, + }, + ...overrides, +}) + +// ---- getHubLocale ---------------------------------------------------------- + +describe("getHubLocale", () => { + beforeEach(() => { + vi.spyOn(chrome.i18n, "getUILanguage") + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("GL-01: returns an exact-match locale", () => { + vi.mocked(chrome.i18n.getUILanguage).mockReturnValue("ja") + expect(getHubLocale()).toBe("ja") + }) + + it("GL-02: resolves locale by prefix match (zh-TW → zh-CN)", () => { + vi.mocked(chrome.i18n.getUILanguage).mockReturnValue("zh-TW") + expect(getHubLocale()).toBe("zh-CN") + }) + + it("GL-03: resolves locale by prefix match (pt-BR → pt-BR)", () => { + vi.mocked(chrome.i18n.getUILanguage).mockReturnValue("pt-BR") + expect(getHubLocale()).toBe("pt-BR") + }) + + it("GL-04: returns default 'en' for unsupported languages", () => { + vi.mocked(chrome.i18n.getUILanguage).mockReturnValue("xx-UNKNOWN") + expect(getHubLocale()).toBe("en") + }) +}) + +// ---- toSubmitCommandInput -------------------------------------------------- + +describe("toSubmitCommandInput", () => { + beforeEach(() => { + vi.spyOn(chrome.i18n, "getUILanguage").mockReturnValue("en") + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + // --- Search commands --- + + it("SC-01: converts a POPUP command correctly", () => { + const cmd = makeSearchCmd({ openMode: OPEN_MODE.POPUP }) + const result = toSubmitCommandInput(cmd) + expect(result).not.toBeNull() + expect(result!.openMode).toBe(OPEN_MODE.POPUP) + expect(result!.targetUrl).toBe("https://google.com/search?q=%s") + expect(result!.commandData).toMatchObject({ + searchUrl: "https://google.com/search?q=%s", + }) + }) + + it("SC-02: converts a TAB command correctly", () => { + const cmd = makeSearchCmd({ openMode: OPEN_MODE.TAB }) + const result = toSubmitCommandInput(cmd) + expect(result).not.toBeNull() + expect(result!.openMode).toBe(OPEN_MODE.TAB) + }) + + it("SC-03: returns null when searchUrl is not set", () => { + const cmd = makeSearchCmd({ searchUrl: undefined }) + expect(toSubmitCommandInput(cmd)).toBeNull() + }) + + it("SC-04: includes openModeSecondary in commandData", () => { + const cmd = makeSearchCmd({ openModeSecondary: OPEN_MODE.TAB }) + const result = toSubmitCommandInput(cmd) + expect(result!.commandData.openModeSecondary).toBe(OPEN_MODE.TAB) + }) + + it("SC-05: includes spaceEncoding in commandData", () => { + const cmd = makeSearchCmd({ spaceEncoding: "plus" as any }) + const result = toSubmitCommandInput(cmd) + expect(result!.commandData.spaceEncoding).toBe("plus") + }) + + it("SC-06: omits openModeSecondary from commandData when not set", () => { + const cmd = makeSearchCmd() + const result = toSubmitCommandInput(cmd) + expect(result!.commandData).not.toHaveProperty("openModeSecondary") + }) + + // --- PAGE_ACTION --- + + it("PA-01: converts a PAGE_ACTION command correctly", () => { + const cmd = makePageActionCmd() + const result = toSubmitCommandInput(cmd) + expect(result).not.toBeNull() + expect(result!.openMode).toBe(OPEN_MODE.PAGE_ACTION) + expect(result!.targetUrl).toBe("https://example.com") + expect(result!.commandData).toMatchObject({ + pageActionOption: { + startUrl: "https://example.com", + steps: [], + openMode: OPEN_MODE.TAB, + }, + }) + }) + + it("PA-02: sets targetUrl to null when startUrl is not set", () => { + const cmd = makePageActionCmd({ + pageActionOption: { + steps: [], + openMode: PAGE_ACTION_OPEN_MODE.TAB, + startUrl: undefined as any, + }, + }) + const result = toSubmitCommandInput(cmd) + expect(result!.targetUrl).toBeNull() + }) + + // --- AI_PROMPT --- + + it("AI-01: converts an AI_PROMPT command correctly (gemini)", () => { + const cmd = makeAiPromptCmd() + const result = toSubmitCommandInput(cmd) + expect(result).not.toBeNull() + expect(result!.openMode).toBe(OPEN_MODE.AI_PROMPT) + expect(result!.targetUrl).toBe("https://gemini.google.com/app") + expect(result!.commandData).toMatchObject({ + aiPromptOption: { + serviceId: "gemini", + prompt: "Summarize: {{SelectedText}}", + openMode: OPEN_MODE.SIDE_PANEL, + }, + }) + }) + + it("AI-02: converts an AI_PROMPT command correctly (chatgpt)", () => { + const cmd = makeAiPromptCmd({ + aiPromptOption: { + serviceId: "chatgpt", + prompt: "Translate: {{SelectedText}}", + openMode: OPEN_MODE.POPUP, + }, + }) + const result = toSubmitCommandInput(cmd) + expect(result!.targetUrl).toBe("https://chatgpt.com") + expect(result!.commandData).toMatchObject({ + aiPromptOption: { serviceId: "chatgpt" }, + }) + }) + + it("AI-03: sets targetUrl to empty string for unknown serviceId", () => { + const cmd = makeAiPromptCmd({ + aiPromptOption: { + serviceId: "unknown-service", + prompt: "Hello", + openMode: OPEN_MODE.POPUP, + }, + }) + const result = toSubmitCommandInput(cmd) + expect(result!.targetUrl).toBe("") + }) + + it("AI-04: inherits title, iconUrl, and locale from baseInput", () => { + const cmd = makeAiPromptCmd() + const result = toSubmitCommandInput(cmd) + expect(result!.title).toBe("Test Command") + expect(result!.iconUrl).toBe("https://example.com/icon.png") + expect(result!.locale).toBe("en") + }) +}) + +// ---- shareCommandToHub ----------------------------------------------------- + +describe("shareCommandToHub", () => { + let mockHubWindow: { postMessage: ReturnType } + + beforeEach(() => { + vi.spyOn(chrome.i18n, "getUILanguage").mockReturnValue("en") + vi.useFakeTimers() + + mockHubWindow = { postMessage: vi.fn() } + vi.spyOn(window, "open").mockReturnValue(mockHubWindow as any) + vi.spyOn(window, "addEventListener") + vi.spyOn(window, "removeEventListener") + }) + + afterEach(() => { + _resetShareState() + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it("SH-01: opens Hub window and returns true for a valid command", () => { + const result = shareCommandToHub(makeSearchCmd()) + expect(result).toBe(true) + expect(window.open).toHaveBeenCalledWith( + expect.stringContaining("/en/dashboard/commands"), + "_blank", + ) + }) + + it("SH-02: returns false when the command has no searchUrl", () => { + const result = shareCommandToHub(makeSearchCmd({ searchUrl: undefined })) + expect(result).toBe(false) + expect(window.open).not.toHaveBeenCalled() + }) + + it("SH-03: returns false when window.open returns null", () => { + vi.mocked(window.open).mockReturnValue(null) + const result = shareCommandToHub(makeSearchCmd()) + expect(result).toBe(false) + }) + + it("SH-04: sends postMessage on each interval tick", () => { + shareCommandToHub(makeSearchCmd()) + vi.advanceTimersByTime(500) + expect(mockHubWindow.postMessage).toHaveBeenCalledTimes(1) + expect(mockHubWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "share-command" }), + expect.any(String), + ) + vi.advanceTimersByTime(500) + expect(mockHubWindow.postMessage).toHaveBeenCalledTimes(2) + }) + + it("SH-05: stops retrying after receiving share-command-ack", () => { + const NEW_HUB_URL = + import.meta.env.VITE_NEW_HUB_URL ?? + "https://selection-command-hub.pages.dev" + + shareCommandToHub(makeSearchCmd()) + + const ackEvent = new MessageEvent("message", { + origin: NEW_HUB_URL, + data: { type: "share-command-ack" }, + }) + window.dispatchEvent(ackEvent) + + const beforeCount = mockHubWindow.postMessage.mock.calls.length + vi.advanceTimersByTime(2000) + expect(mockHubWindow.postMessage.mock.calls.length).toBe(beforeCount) + }) + + it("SH-06: can share an AI_PROMPT command", () => { + const result = shareCommandToHub(makeAiPromptCmd()) + expect(result).toBe(true) + vi.advanceTimersByTime(500) + expect(mockHubWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "share-command", + command: expect.objectContaining({ + openMode: OPEN_MODE.AI_PROMPT, + targetUrl: "https://gemini.google.com/app", + }), + }), + expect.any(String), + ) + }) + + it("SH-07: can share a PAGE_ACTION command", () => { + const result = shareCommandToHub(makePageActionCmd()) + expect(result).toBe(true) + vi.advanceTimersByTime(500) + expect(mockHubWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "share-command", + command: expect.objectContaining({ + openMode: OPEN_MODE.PAGE_ACTION, + targetUrl: "https://example.com", + }), + }), + expect.any(String), + ) + }) +}) diff --git a/packages/extension/src/services/hubShare.ts b/packages/extension/src/services/hubShare.ts new file mode 100644 index 00000000..10e05290 --- /dev/null +++ b/packages/extension/src/services/hubShare.ts @@ -0,0 +1,185 @@ +import { + NEW_HUB_URL, + NEW_HUB_SUPPORTED_LOCALES, + OPEN_MODE, + type NewHubLocale, +} from "@/const" +import { getAiServicesFallback } from "@/services/aiPromptFallback" +import type { + SelectionCommand, + SearchCommand, + PageActionCommand, + AiPromptCommand, +} from "@/types" + +// ---- Type definitions ------------------------------------------------------ + +export interface SubmitCommandInput { + title: string + description?: string + iconUrl?: string + targetUrl: string | null + openMode: string + commandData: Record + locale: string + tags?: string[] +} + +// ---- Locale resolution ----------------------------------------------------- + +export function getHubLocale(): NewHubLocale { + const uiLang = chrome.i18n.getUILanguage() + const lang = (uiLang || navigator.language || "en").toLowerCase() + + // Exact match + for (const locale of NEW_HUB_SUPPORTED_LOCALES) { + if (lang === locale.toLowerCase()) return locale + } + // Prefix match (e.g. "zh-tw" → "zh-CN", "pt-br" → "pt-BR"). + for (const locale of NEW_HUB_SUPPORTED_LOCALES) { + if (lang.startsWith(locale.split("-")[0].toLowerCase())) return locale + } + return "en" +} + +// ---- Command data conversion ----------------------------------------------- + +export function toSubmitCommandInput( + cmd: SelectionCommand, +): SubmitCommandInput | null { + const { title, iconUrl, openMode } = cmd + + const baseInput = { + title, + iconUrl, + openMode, + locale: getHubLocale(), + } + + if (openMode === OPEN_MODE.AI_PROMPT) { + const ai = cmd as AiPromptCommand + const { serviceId } = ai.aiPromptOption + const service = getAiServicesFallback().find((s) => s.id === serviceId) + const targetUrl = service?.url ?? "" + return { + ...baseInput, + targetUrl, + commandData: { + aiPromptOption: { + serviceId, + prompt: ai.aiPromptOption.prompt, + openMode: ai.aiPromptOption.openMode, + }, + }, + } + } + + if (openMode === OPEN_MODE.PAGE_ACTION) { + const pa = cmd as PageActionCommand + const targetUrl = pa.pageActionOption?.startUrl ?? null + return { + ...baseInput, + targetUrl, + commandData: { + pageActionOption: { + steps: pa.pageActionOption.steps, + startUrl: pa.pageActionOption.startUrl, + openMode: pa.pageActionOption.openMode, + }, + }, + } + } + + // Search-based commands (popup / tab / window / backgroundTab / sidePanel) + const sc = cmd as SearchCommand + if (!sc.searchUrl) return null + + const targetUrl = sc.searchUrl + const commandData: Record = { searchUrl: sc.searchUrl } + if (sc.openModeSecondary) commandData.openModeSecondary = sc.openModeSecondary + if (sc.spaceEncoding) commandData.spaceEncoding = sc.spaceEncoding + + return { + ...baseInput, + targetUrl, + commandData, + } +} + +// ---- Share main logic ------------------------------------------------------ + +const RETRY_INTERVAL_MS = 500 +const MAX_RETRIES = 20 // 10 seconds + +let isSharing = false + +// Exposed for testing purposes to reset the sharing state between tests +export function _resetShareState(): void { + isSharing = false +} + +export function shareCommandToHub(command: SelectionCommand): boolean { + if (isSharing) return false + + const input = toSubmitCommandInput(command) + if (!input) { + console.warn( + "Unsupported command type or missing data. Cannot share to Hub.", + ) + return false + } + + const hubUrl = `${NEW_HUB_URL}/${input.locale}/dashboard/commands` + const hubWindow = window.open(hubUrl, "_blank") + if (!hubWindow) { + console.error("[HubShare] Failed to open Hub page.") + return false + } + + isSharing = true + let retries = 0 + + const cleanup = () => { + clearInterval(timer) + window.removeEventListener("message", onAck) + isSharing = false + } + + // Stop retrying once an ack is received from the Hub + const onAck = (event: MessageEvent) => { + if (event.origin !== NEW_HUB_URL) return + if ((event.data as { type?: string })?.type === "share-command-ack") { + cleanup() + } + } + window.addEventListener("message", onAck) + + const timer = setInterval(() => { + retries++ + if (hubWindow.closed) { + cleanup() + return + } + if (retries > MAX_RETRIES) { + cleanup() + console.error("[HubShare] Hub page did not respond in time.") + return + } + try { + hubWindow.postMessage( + { type: "share-command", command: input }, + NEW_HUB_URL, + ) + // Keep the interval running until ack is received + } catch (err) { + if (err instanceof DOMException) { + // Hub window still loading — retry on next tick + } else { + cleanup() + console.error("[HubShare] Unexpected error during postMessage:", err) + } + } + }, RETRY_INTERVAL_MS) + + return true +}