diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index f764af2e..78b12665 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -1005,6 +1005,15 @@ "review_request_button": { "message": "Bewertung schreiben" }, + "hub_share_toast_message": { + "message": "Möchten Sie diesen Befehl mit dem Selection Command Hub teilen?" + }, + "hub_share_toast_button": { + "message": "Teilen" + }, + "hub_share_toast_later": { + "message": "Später" + }, "Option_shortcuts": { "message": "Tastenkombinationen" }, diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index 0b9b7bf6..97100f56 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -1016,6 +1016,17 @@ "review_request_button": { "message": "Write a Review" }, + "hub_share_toast_message": { + "message": "Would you like to share this command with the Selection Command Hub?", + "description": "Toast message shown once, right after a new command is created, inviting the user to share it to the Hub." + }, + "hub_share_toast_button": { + "message": "Share", + "description": "Button label to share the newly created command to the Hub from the toast." + }, + "hub_share_toast_later": { + "message": "Later" + }, "Option_shortcuts": { "message": "Shortcut Keys" }, diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index 903c4d2a..1f8eff56 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -1005,6 +1005,15 @@ "review_request_button": { "message": "Escribir una Reseña" }, + "hub_share_toast_message": { + "message": "¿Quieres compartir este comando con Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Compartir" + }, + "hub_share_toast_later": { + "message": "Luego" + }, "Option_shortcuts": { "message": "Teclas de Acceso Rápido" }, diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index cad04293..29878326 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -1005,6 +1005,15 @@ "review_request_button": { "message": "Écrire un Avis" }, + "hub_share_toast_message": { + "message": "Souhaitez-vous partager cette commande avec Selection Command Hub ?" + }, + "hub_share_toast_button": { + "message": "Partager" + }, + "hub_share_toast_later": { + "message": "Plus tard" + }, "Option_shortcuts": { "message": "Raccourcis Clavier" }, diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index 69239095..96278ef8 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -1005,6 +1005,15 @@ "review_request_button": { "message": "समीक्षा लिखें" }, + "hub_share_toast_message": { + "message": "क्या आप इस कमांड को Selection Command Hub पर साझा करना चाहेंगे?" + }, + "hub_share_toast_button": { + "message": "साझा करें" + }, + "hub_share_toast_later": { + "message": "बाद में" + }, "Option_shortcuts": { "message": "शॉर्टकट" }, diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index 0597fdcc..21cd7763 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -1005,6 +1005,15 @@ "review_request_button": { "message": "Tulis Ulasan" }, + "hub_share_toast_message": { + "message": "Ingin membagikan perintah ini ke Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Bagikan" + }, + "hub_share_toast_later": { + "message": "Nanti" + }, "Option_shortcuts": { "message": "Tombol Pintas" }, diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index 9812eefd..dc68a70e 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -1005,6 +1005,15 @@ "review_request_button": { "message": "Scrivi recensione" }, + "hub_share_toast_message": { + "message": "Vuoi condividere questo comando con Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Condividi" + }, + "hub_share_toast_later": { + "message": "Dopo" + }, "Option_shortcuts": { "message": "Scorciatoie" }, diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index b3cc1964..f3558be0 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -1002,6 +1002,15 @@ "review_request_button": { "message": "レビューする" }, + "hub_share_toast_message": { + "message": "コマンドをSelection Command Hubへ共有しませんか?" + }, + "hub_share_toast_button": { + "message": "共有する" + }, + "hub_share_toast_later": { + "message": "後で" + }, "Option_shortcuts": { "message": "ショートカットキー" }, diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index 4bad7ab6..6f405c36 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -1005,6 +1005,15 @@ "review_request_button": { "message": "리뷰 작성" }, + "hub_share_toast_message": { + "message": "이 명령을 Selection Command Hub에 공유하시겠습니까?" + }, + "hub_share_toast_button": { + "message": "공유하기" + }, + "hub_share_toast_later": { + "message": "나중에" + }, "Option_shortcuts": { "message": "단축키" }, diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index 6784ef86..45462caa 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -1005,6 +1005,15 @@ "review_request_button": { "message": "Tulis Ulasan" }, + "hub_share_toast_message": { + "message": "Adakah anda ingin berkongsi arahan ini ke Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Kongsi" + }, + "hub_share_toast_later": { + "message": "Kemudian" + }, "Option_shortcuts": { "message": "Kekunci Pintas" }, diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index 40564d93..4c8f1651 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -1005,6 +1005,15 @@ "review_request_button": { "message": "Escrever uma Avaliação" }, + "hub_share_toast_message": { + "message": "Deseja compartilhar este comando com o Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Compartilhar" + }, + "hub_share_toast_later": { + "message": "Mais tarde" + }, "Option_shortcuts": { "message": "Teclas de Atalho" }, diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index 7074a4cb..98283d41 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -1005,6 +1005,15 @@ "review_request_button": { "message": "Escrever uma Avaliação" }, + "hub_share_toast_message": { + "message": "Deseja partilhar este comando com o Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Partilhar" + }, + "hub_share_toast_later": { + "message": "Mais tarde" + }, "Option_shortcuts": { "message": "Teclas de Atalho" }, diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index df41a820..e020a656 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -1005,6 +1005,15 @@ "review_request_button": { "message": "Написать отзыв" }, + "hub_share_toast_message": { + "message": "Хотите поделиться этой командой в Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Поделиться" + }, + "hub_share_toast_later": { + "message": "Позже" + }, "Option_shortcuts": { "message": "Горячие клавиши" }, diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index 7eee5568..baee2109 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -1005,6 +1005,15 @@ "review_request_button": { "message": "写评论" }, + "hub_share_toast_message": { + "message": "要将此命令分享到 Selection Command Hub 吗?" + }, + "hub_share_toast_button": { + "message": "分享" + }, + "hub_share_toast_later": { + "message": "稍后" + }, "Option_shortcuts": { "message": "快捷键" }, diff --git a/packages/extension/src/components/option/HubShareToast.test.tsx b/packages/extension/src/components/option/HubShareToast.test.tsx new file mode 100644 index 00000000..bedbf4d0 --- /dev/null +++ b/packages/extension/src/components/option/HubShareToast.test.tsx @@ -0,0 +1,152 @@ +import { render, screen } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import userEvent from "@testing-library/user-event" +import { toast } from "sonner" +import { showHubShareToast } from "./HubShareToast" +import { sendEvent } from "@/services/analytics" +import { shareCommandToHub } from "@/services/hubShare" +import { OPEN_MODE } from "@/const" +import type { SearchCommand } from "@/types" + +vi.mock("sonner", () => ({ + toast: { + custom: vi.fn(), + dismiss: vi.fn(), + }, +})) + +vi.mock("@/services/i18n", () => ({ + t: vi.fn((key: string) => { + const translations: Record = { + hub_share_toast_message: "Share this command to the Hub?", + hub_share_toast_later: "Later", + hub_share_toast_button: "Share", + } + return translations[key] || key + }), +})) + +vi.mock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { + OPEN_DIALOG: "open_dialog", + COMMAND_SHARE: "command_share", + }, + sendEvent: vi.fn(), +})) + +vi.mock("@/services/hubShare", () => ({ + shareCommandToHub: vi.fn(), +})) + +const mockToastCustom = vi.mocked(toast.custom) +const mockToastDismiss = vi.mocked(toast.dismiss) +const mockSendEvent = vi.mocked(sendEvent) +const mockShareCommandToHub = vi.mocked(shareCommandToHub) + +const command: SearchCommand = { + id: "cmd-1", + title: "Test Command", + iconUrl: "https://example.com/icon.png", + openMode: OPEN_MODE.POPUP, + searchUrl: "https://google.com/search?q=%s", +} + +// Renders the JSX passed to toast.custom() so its buttons can be interacted with. +function renderToastBody(toastId: string | number = "toast-1") { + const [renderFn] = mockToastCustom.mock.calls[0] + return render(<>{renderFn(toastId)}) +} + +describe("showHubShareToast", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("HST-01: sends an open_dialog analytics event after the show delay", () => { + showHubShareToast(command, vi.fn()) + expect(mockSendEvent).not.toHaveBeenCalled() + + vi.advanceTimersByTime(2000) + + expect(mockSendEvent).toHaveBeenCalledWith( + "open_dialog", + { event_label: "hub_share_toast" }, + expect.anything(), + ) + }) + + it("HST-02: calls toast.custom with a 60 second duration after the show delay", () => { + showHubShareToast(command, vi.fn()) + expect(mockToastCustom).not.toHaveBeenCalled() + + vi.advanceTimersByTime(2000) + + expect(mockToastCustom).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ duration: 60 * 1000 }), + ) + }) + + it("HST-03: renders the message and both buttons", () => { + showHubShareToast(command, vi.fn()) + vi.advanceTimersByTime(2000) + renderToastBody() + + expect( + screen.getByText("Share this command to the Hub?"), + ).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Later" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Share/ })).toBeInTheDocument() + }) + + it("HST-04: clicking 'Later' dismisses the toast and calls onShown without sharing", async () => { + const onShown = vi.fn() + showHubShareToast(command, onShown) + vi.advanceTimersByTime(2000) + vi.useRealTimers() + const user = userEvent.setup() + renderToastBody("toast-1") + + await user.click(screen.getByRole("button", { name: "Later" })) + + expect(mockToastDismiss).toHaveBeenCalledWith("toast-1") + expect(onShown).toHaveBeenCalledOnce() + expect(mockShareCommandToHub).not.toHaveBeenCalled() + }) + + it("HST-05: clicking 'Share' shares the command, dismisses the toast, and calls onShown", async () => { + const onShown = vi.fn() + showHubShareToast(command, onShown) + vi.advanceTimersByTime(2000) + vi.useRealTimers() + const user = userEvent.setup() + renderToastBody("toast-1") + + await user.click(screen.getByRole("button", { name: /Share/ })) + + expect(mockShareCommandToHub).toHaveBeenCalledWith(command) + expect(mockSendEvent).toHaveBeenCalledWith( + "command_share", + { event_label: "hub-share-toast" }, + expect.anything(), + ) + expect(mockToastDismiss).toHaveBeenCalledWith("toast-1") + expect(onShown).toHaveBeenCalledOnce() + }) + + it("HST-06: calls onShown when the toast auto-closes via timeout", () => { + const onShown = vi.fn() + showHubShareToast(command, onShown) + vi.advanceTimersByTime(2000) + + const [, options] = mockToastCustom.mock.calls[0] + options?.onAutoClose?.({} as never) + + expect(onShown).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/extension/src/components/option/HubShareToast.tsx b/packages/extension/src/components/option/HubShareToast.tsx new file mode 100644 index 00000000..8123a5f3 --- /dev/null +++ b/packages/extension/src/components/option/HubShareToast.tsx @@ -0,0 +1,94 @@ +import { toast } from "sonner" +import { Share } from "lucide-react" +import { t } from "@/services/i18n" +import { cn } from "@/lib/utils" +import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" +import { SCREEN } from "@/const" +import { shareCommandToHub } from "@/services/hubShare" +import type { SelectionCommand } from "@/types" + +// Delay before showing the toast so it doesn't overlap the dialog's close animation. +const SHOW_DELAY_MS = 2000 + +/** + * Shows a one-time toast on the options page suggesting the user share a + * newly created command to the Selection Command Hub. `onShown` is invoked + * whenever the toast is dismissed — by clicking either button, or via the + * auto-close timeout — so callers can persist a "don't show again" flag as + * soon as the toast has been presented. + */ +export function showHubShareToast( + command: SelectionCommand, + onShown: () => void, +): void { + setTimeout(() => { + showHubShareToastNow(command, onShown) + }, SHOW_DELAY_MS) +} + +function showHubShareToastNow( + command: SelectionCommand, + onShown: () => void, +): void { + sendEvent( + ANALYTICS_EVENTS.OPEN_DIALOG, + { + event_label: "hub_share_toast", + }, + SCREEN.OPTION, + ) + + toast.custom( + (toastId) => ( +
+
+ Selection Command Hub + {t("hub_share_toast_message")} +
+
+ + +
+
+ ), + { + duration: 60 * 1000, + onAutoClose: () => { + onShown() + }, + }, + ) +} diff --git a/packages/extension/src/components/option/ImportExport.tsx b/packages/extension/src/components/option/ImportExport.tsx index 15da5cab..c894f5d1 100644 --- a/packages/extension/src/components/option/ImportExport.tsx +++ b/packages/extension/src/components/option/ImportExport.tsx @@ -269,12 +269,14 @@ export function ImportExport() { commandExecutionCount = 0, hasShownReviewRequest = false, hasDismissedPromptHistoryBanner = false, + hasShownHubShareToast = false, } = await enhancedSettings.get() const data = await migrate({ ...importJson, commandExecutionCount, hasShownReviewRequest, hasDismissedPromptHistoryBanner, + hasShownHubShareToast, stars: [], }) await Settings.set(data) diff --git a/packages/extension/src/components/option/Option.tsx b/packages/extension/src/components/option/Option.tsx index 0d18a1c6..940249c2 100644 --- a/packages/extension/src/components/option/Option.tsx +++ b/packages/extension/src/components/option/Option.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react" +import { Toaster } from "sonner" import { Settings } from "@/services/settings/settings" import { capitalize, scrollToSelector } from "@/lib/utils" @@ -48,6 +49,7 @@ export function Option() { return (
+

{APP_ID.split("-").map((n) => { diff --git a/packages/extension/src/components/option/ShareButton.tsx b/packages/extension/src/components/option/ShareButton.tsx index 0ed18953..e877f0f0 100644 --- a/packages/extension/src/components/option/ShareButton.tsx +++ b/packages/extension/src/components/option/ShareButton.tsx @@ -3,27 +3,17 @@ import { Share, CloudCheck } from "lucide-react" import { Tooltip } from "@/components/Tooltip" import { cn, isUUIDv7, generateId } from "@/lib/utils" import { t } from "@/services/i18n" -import { shareCommandToHub, getHubLocale } from "@/services/hubShare" +import { + shareCommandToHub, + getHubLocale, + isHubShareable, +} from "@/services/hubShare" import { sendEvent, ANALYTICS_EVENTS } from "@/services/analytics" import { Storage, LOCAL_STORAGE_KEY } from "@/services/storage" -import { - NEW_HUB_SHAREABLE_OPEN_MODES, - COMMAND_SOURCE_TYPE, - NEW_HUB_URL, - HUB_SHARE_EXCLUDED_IDS, - IS_SUPPORT_BUILD, - SCREEN, -} from "@/const" +import { NEW_HUB_URL, SCREEN } from "@/const" import type { SelectionCommand } from "@/types" import { TEST_IDS } from "@/testIds" -const VALID_SOURCE_TYPES = new Set([ - COMMAND_SOURCE_TYPE.SELF_CREATED, - COMMAND_SOURCE_TYPE.SELF_UPDATED, - COMMAND_SOURCE_TYPE.SELF_REINSTALL, - COMMAND_SOURCE_TYPE.UNKNOWN, -]) - type Props = { command: SelectionCommand onCommandIdChange?: (newId: string) => void @@ -52,7 +42,7 @@ export const ShareButton = ({ } // Disable the button immediately to prevent duplicate shares/signup - // tabs from rapid repeated clicks while the storage lookup below runs. + // tabs from rapid repeated clicks while the lookup below runs. setStatus("pending") let commandToShare = command @@ -92,14 +82,7 @@ export const ShareButton = ({ ) } - if ( - !IS_SUPPORT_BUILD && - (HUB_SHARE_EXCLUDED_IDS.has(command.id) || - !NEW_HUB_SHAREABLE_OPEN_MODES.has(command.openMode) || - !VALID_SOURCE_TYPES.has( - command.sourceType ?? COMMAND_SOURCE_TYPE.UNKNOWN, - )) - ) { + if (!isHubShareable(command)) { return null } diff --git a/packages/extension/src/components/option/editor/CommandList.hubShareToast.test.tsx b/packages/extension/src/components/option/editor/CommandList.hubShareToast.test.tsx new file mode 100644 index 00000000..26b503bc --- /dev/null +++ b/packages/extension/src/components/option/editor/CommandList.hubShareToast.test.tsx @@ -0,0 +1,197 @@ +import { render, screen, waitFor } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import userEvent from "@testing-library/user-event" +import { useForm } from "react-hook-form" +import { CommandList } from "./CommandList" +import { isHubShareable } from "@/services/hubShare" +import { showHubShareToast } from "@/components/option/HubShareToast" +import { enhancedSettings } from "@/services/settings/enhancedSettings" +import { Settings } from "@/services/settings/settings" +import { OPEN_MODE } from "@/const" +import type { SelectionCommand } from "@/types" + +// Renders as a plain button so the test can trigger onAddCommand directly, +// bypassing the real menu UI. +vi.mock("./CommandListMenu", () => ({ + CommandListMenu: (props: any) => ( + + ), +})) + +vi.mock("@/components/option/editor/CommandTypeSelectionDialog", () => ({ + CommandTypeSelectionDialog: (props: any) => + props.open ? ( + + ) : null, +})) + +let nextSubmittedCommand: SelectionCommand | null = null + +vi.mock("@/components/option/editor/CommandEditDialog", () => ({ + CommandEditDialog: (props: any) => + props.open ? ( + + ) : null, +})) + +vi.mock("@/components/option/editor/FolderEditDialog", () => ({ + FolderEditDialog: () => null, +})) + +vi.mock("./CommandTreeRenderer", () => ({ + CommandTreeRenderer: () => null, +})) + +vi.mock("@dnd-kit/core", () => ({ + DndContext: (props: any) => <>{props.children}, + closestCenter: vi.fn(), + KeyboardSensor: vi.fn(), + PointerSensor: vi.fn(), + useSensor: vi.fn(() => ({})), + useSensors: vi.fn(() => []), +})) + +vi.mock("@dnd-kit/sortable", () => ({ + SortableContext: (props: any) => <>{props.children}, + sortableKeyboardCoordinates: vi.fn(), + verticalListSortingStrategy: {}, +})) + +vi.mock("@/hooks/option/useCommandActions", () => ({ + useCommandActions: () => ({ commandRemove: vi.fn() }), +})) + +vi.mock("@/hooks/option/useCommandDragDrop", () => ({ + useCommandDragDrop: () => ({ handleDragEnd: vi.fn() }), +})) + +vi.mock("@/hooks/option/useSharedCommandIds", () => ({ + useSharedCommandIds: () => new Set(), +})) + +vi.mock("@/services/hubShare", () => ({ + editCommandToHub: vi.fn(), + isHubShareable: vi.fn(), +})) + +vi.mock("@/components/option/HubShareToast", () => ({ + showHubShareToast: vi.fn(), +})) + +vi.mock("@/services/settings/enhancedSettings", () => ({ + enhancedSettings: { + getSection: vi.fn(), + }, +})) + +vi.mock("@/services/settings/settings", () => ({ + Settings: { + update: vi.fn(), + updateCommandId: vi.fn(), + }, +})) + +vi.mock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { + OPEN_DIALOG: "open_dialog", + COMMAND_ADD: "command_add", + COMMAND_EDIT: "command_edit", + COMMAND_REMOVE: "command_remove", + }, + sendEvent: vi.fn(), +})) + +const mockIsHubShareable = vi.mocked(isHubShareable) +const mockShowHubShareToast = vi.mocked(showHubShareToast) +const mockGetSection = vi.mocked(enhancedSettings.getSection) +const mockSettingsUpdate = vi.mocked(Settings.update) + +function Wrapper() { + const { control } = useForm({ + defaultValues: { commands: [], folders: [] }, + }) + return +} + +const newCommand: SelectionCommand = { + id: "new-cmd-1", + title: "New Command", + openMode: OPEN_MODE.POPUP, + searchUrl: "https://example.com/search?q=%s", +} as SelectionCommand + +async function addNewCommand(user: ReturnType) { + await user.click(screen.getByText("add-command")) + await user.click(screen.getByText("select-type")) + await user.click(screen.getByText("submit-command")) +} + +describe("CommandList: hub share toast trigger on new command creation", () => { + beforeEach(() => { + vi.clearAllMocks() + nextSubmittedCommand = newCommand + mockGetSection.mockResolvedValue({ hasShownHubShareToast: false } as any) + mockIsHubShareable.mockReturnValue(true) + }) + + it("CL-01: shows the hub share toast when the command is shareable and not yet shown", async () => { + const user = userEvent.setup() + render() + + await addNewCommand(user) + + await waitFor(() => { + expect(mockShowHubShareToast).toHaveBeenCalledWith( + newCommand, + expect.any(Function), + ) + }) + }) + + it("CL-02: does not show the toast when hasShownHubShareToast is already true", async () => { + mockGetSection.mockResolvedValue({ hasShownHubShareToast: true } as any) + const user = userEvent.setup() + render() + + await addNewCommand(user) + + await waitFor(() => { + expect(mockGetSection).toHaveBeenCalled() + }) + expect(mockShowHubShareToast).not.toHaveBeenCalled() + }) + + it("CL-03: does not show the toast when the command is not hub-shareable", async () => { + mockIsHubShareable.mockReturnValue(false) + const user = userEvent.setup() + render() + + await addNewCommand(user) + + await waitFor(() => { + expect(mockGetSection).toHaveBeenCalled() + }) + expect(mockShowHubShareToast).not.toHaveBeenCalled() + }) + + it("CL-04: the onShown callback persists the hasShownHubShareToast flag", async () => { + const user = userEvent.setup() + render() + + await addNewCommand(user) + + await waitFor(() => { + expect(mockShowHubShareToast).toHaveBeenCalled() + }) + + const onShown = mockShowHubShareToast.mock.calls[0][1] + onShown() + + expect(mockSettingsUpdate).toHaveBeenCalledWith( + "hasShownHubShareToast", + expect.any(Function), + ) + }) +}) diff --git a/packages/extension/src/components/option/editor/CommandList.tsx b/packages/extension/src/components/option/editor/CommandList.tsx index e0f1125d..5dd603d7 100644 --- a/packages/extension/src/components/option/editor/CommandList.tsx +++ b/packages/extension/src/components/option/editor/CommandList.tsx @@ -42,7 +42,10 @@ import { getDescendantFolderIds, } from "@/services/option/commandUtils" import { isValidDrop } from "@/services/option/dragAndDrop" -import { editCommandToHub } from "@/services/hubShare" +import { editCommandToHub, isHubShareable } from "@/services/hubShare" +import { showHubShareToast } from "@/components/option/HubShareToast" +import { CACHE_SECTIONS } from "@/services/settings/settingsCache" +import { enhancedSettings } from "@/services/settings/enhancedSettings" import { Settings } from "@/services/settings/settings" import { useCommandActions } from "@/hooks/option/useCommandActions" import { useCommandDragDrop } from "@/hooks/option/useCommandDragDrop" @@ -240,6 +243,24 @@ export const CommandList = ({ control }: CommandListProps) => { }, SCREEN.OPTION, ) + enhancedSettings + .getSection(CACHE_SECTIONS.USER_STATS) + .then((userStats) => { + if ( + !userStats.hasShownHubShareToast && + isHubShareable(data as SelectionCommand) + ) { + showHubShareToast(data as SelectionCommand, () => { + Settings.update("hasShownHubShareToast", () => true) + }) + } + }) + .catch((err) => { + console.error( + "[CommandList] Failed to load user stats for hub share toast:", + err, + ) + }) } } else { const idx = folderArray.fields.findIndex((f) => f.id === data.id) diff --git a/packages/extension/src/hooks/useSettings.test.tsx b/packages/extension/src/hooks/useSettings.test.tsx index 74b297be..15888080 100644 --- a/packages/extension/src/hooks/useSettings.test.tsx +++ b/packages/extension/src/hooks/useSettings.test.tsx @@ -375,6 +375,7 @@ describe("useSettings hooks", () => { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, } as SettingsType mockEnhancedSettings.getSection.mockResolvedValueOnce(mockSettings) @@ -694,6 +695,7 @@ describe("useSettings hooks", () => { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, } as SettingsType mockEnhancedSettings.getSection.mockResolvedValueOnce(mockSettings) diff --git a/packages/extension/src/services/hubShare.test.ts b/packages/extension/src/services/hubShare.test.ts index d395b585..532900bd 100644 --- a/packages/extension/src/services/hubShare.test.ts +++ b/packages/extension/src/services/hubShare.test.ts @@ -3,9 +3,10 @@ import { getHubLocale, toSubmitCommandInput, shareCommandToHub, + isHubShareable, } from "./hubShare" import { Ipc, BgCommand } from "@/services/ipc" -import { OPEN_MODE, PAGE_ACTION_OPEN_MODE } from "@/const" +import { OPEN_MODE, PAGE_ACTION_OPEN_MODE, COMMAND_SOURCE_TYPE } from "@/const" import type { SearchCommand, PageActionCommand, AiPromptCommand } from "@/types" // Mock the IPC module so that shareCommandToHub does not trigger real messaging @@ -292,3 +293,91 @@ describe("shareCommandToHub", () => { }) }) +// ---- isHubShareable --------------------------------------------------------- + +describe("isHubShareable", () => { + it("HS-01: returns false for a command in the exclusion list", () => { + const cmd = makeSearchCmd({ + id: "0", + sourceType: COMMAND_SOURCE_TYPE.SELF_CREATED, + }) + expect(isHubShareable(cmd)).toBe(false) + }) + + it("HS-02: returns false for a non-shareable openMode", () => { + const cmd = makeSearchCmd({ + openMode: OPEN_MODE.COPY, + sourceType: COMMAND_SOURCE_TYPE.SELF_CREATED, + }) + expect(isHubShareable(cmd)).toBe(false) + }) + + it("HS-03: returns false for a DEFAULT sourceType", () => { + const cmd = makeSearchCmd({ sourceType: COMMAND_SOURCE_TYPE.DEFAULT }) + expect(isHubShareable(cmd)).toBe(false) + }) + + it("HS-04: returns false for a HUB_COMMUNITY sourceType", () => { + const cmd = makeSearchCmd({ + sourceType: COMMAND_SOURCE_TYPE.HUB_COMMUNITY, + }) + expect(isHubShareable(cmd)).toBe(false) + }) + + it("HS-05: returns true for a SELF_CREATED sourceType", () => { + const cmd = makeSearchCmd({ + sourceType: COMMAND_SOURCE_TYPE.SELF_CREATED, + }) + expect(isHubShareable(cmd)).toBe(true) + }) + + it("HS-06: returns true for a SELF_UPDATED sourceType", () => { + const cmd = makeSearchCmd({ + sourceType: COMMAND_SOURCE_TYPE.SELF_UPDATED, + }) + expect(isHubShareable(cmd)).toBe(true) + }) + + it("HS-07: returns true for a SELF_REINSTALL sourceType", () => { + const cmd = makeSearchCmd({ + sourceType: COMMAND_SOURCE_TYPE.SELF_REINSTALL, + }) + expect(isHubShareable(cmd)).toBe(true) + }) + + it("HS-08: treats a missing sourceType as UNKNOWN and returns true", () => { + const cmd = makeSearchCmd({ sourceType: undefined }) + expect(isHubShareable(cmd)).toBe(true) + }) + + it("HS-09: returns true when all eligibility conditions are met", () => { + const cmd = makeSearchCmd({ + id: "not-excluded", + openMode: OPEN_MODE.TAB, + sourceType: COMMAND_SOURCE_TYPE.SELF_CREATED, + }) + expect(isHubShareable(cmd)).toBe(true) + }) +}) + +describe("isHubShareable (support build)", () => { + beforeEach(() => { + vi.resetModules() + vi.stubEnv("VITE_SUPPORT_BUILD", "true") + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it("HS-10: bypasses all eligibility checks when IS_SUPPORT_BUILD is true", async () => { + const { isHubShareable: isHubShareableSupportBuild } = + await import("./hubShare") + const cmd = makeSearchCmd({ + id: "0", // normally excluded + openMode: OPEN_MODE.COPY, // normally non-shareable + sourceType: COMMAND_SOURCE_TYPE.DEFAULT, // normally non-shareable + }) + expect(isHubShareableSupportBuild(cmd)).toBe(true) + }) +}) diff --git a/packages/extension/src/services/hubShare.ts b/packages/extension/src/services/hubShare.ts index 7265ba81..ecd59324 100644 --- a/packages/extension/src/services/hubShare.ts +++ b/packages/extension/src/services/hubShare.ts @@ -1,9 +1,23 @@ -import { NEW_HUB_SUPPORTED_LOCALES, type NewHubLocale } from "@/const" +import { + NEW_HUB_SUPPORTED_LOCALES, + NEW_HUB_SHAREABLE_OPEN_MODES, + HUB_SHARE_EXCLUDED_IDS, + COMMAND_SOURCE_TYPE, + IS_SUPPORT_BUILD, + type NewHubLocale, +} from "@/const" import { getAiServicesFallback } from "@/services/aiPromptFallback" import { isAiPromptCommand, isPageActionCommand } from "@/lib/utils" import { Ipc, BgCommand } from "@/services/ipc" import type { SelectionCommand, SearchCommand } from "@/types" +const HUB_SHAREABLE_SOURCE_TYPES = new Set([ + COMMAND_SOURCE_TYPE.SELF_CREATED, + COMMAND_SOURCE_TYPE.SELF_UPDATED, + COMMAND_SOURCE_TYPE.SELF_REINSTALL, + COMMAND_SOURCE_TYPE.UNKNOWN, +]) + // ---- Type definitions ------------------------------------------------------ export type SubmitCommandInput = { @@ -60,6 +74,24 @@ export function toSubmitCommandInput( } } +// ---- Eligibility check ------------------------------------------------------ + +/** + * Determines whether a command is eligible to be shared to the Hub. + * Support builds bypass this check to make testing the share flow easier. + */ +export function isHubShareable(command: SelectionCommand): boolean { + if (IS_SUPPORT_BUILD) return true + + return ( + !HUB_SHARE_EXCLUDED_IDS.has(command.id) && + NEW_HUB_SHAREABLE_OPEN_MODES.has(command.openMode) && + HUB_SHAREABLE_SOURCE_TYPES.has( + command.sourceType ?? COMMAND_SOURCE_TYPE.UNKNOWN, + ) + ) +} + // ---- Share main logic ------------------------------------------------------ export function shareCommandToHub(command: SelectionCommand): boolean { diff --git a/packages/extension/src/services/option/defaultSettings.ts b/packages/extension/src/services/option/defaultSettings.ts index 73ad617b..aa50c24f 100644 --- a/packages/extension/src/services/option/defaultSettings.ts +++ b/packages/extension/src/services/option/defaultSettings.ts @@ -66,6 +66,7 @@ export const emptySettings: SettingsType = { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, shortcuts: { shortcuts: [] }, windowOption: { sidePanelAutoHide: false, diff --git a/packages/extension/src/services/settings/enhancedSettings.ts b/packages/extension/src/services/settings/enhancedSettings.ts index 13fc93fb..0a5b1fe2 100644 --- a/packages/extension/src/services/settings/enhancedSettings.ts +++ b/packages/extension/src/services/settings/enhancedSettings.ts @@ -47,26 +47,27 @@ export class EnhancedSettings { : Promise.resolve([]), sections.includes(CACHE_SECTIONS.USER_SETTINGS) ? settingsCache.get( - CACHE_SECTIONS.USER_SETTINGS, - forceFresh, - ) + CACHE_SECTIONS.USER_SETTINGS, + forceFresh, + ) : Promise.resolve(DefaultSettings as UserSettings), sections.includes(CACHE_SECTIONS.STARS) ? settingsCache.get(CACHE_SECTIONS.STARS, forceFresh) : Promise.resolve([]), sections.includes(CACHE_SECTIONS.SHORTCUTS) ? settingsCache.get( - CACHE_SECTIONS.SHORTCUTS, - forceFresh, - ) + CACHE_SECTIONS.SHORTCUTS, + forceFresh, + ) : Promise.resolve({ shortcuts: [] }), sections.includes(CACHE_SECTIONS.USER_STATS) ? settingsCache.get(CACHE_SECTIONS.USER_STATS, forceFresh) : Promise.resolve({ - commandExecutionCount: 0, - hasShownReviewRequest: false, - hasDismissedPromptHistoryBanner: false, - }), + commandExecutionCount: 0, + hasShownReviewRequest: false, + hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, + }), ]) // Process results @@ -93,10 +94,11 @@ export class EnhancedSettings { userStatsResult.status === "fulfilled" ? userStatsResult.value : { - commandExecutionCount: 0, - hasShownReviewRequest: false, - hasDismissedPromptHistoryBanner: false, - } + commandExecutionCount: 0, + hasShownReviewRequest: false, + hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, + } // Merge settings const mergedSettings = this.mergeSettings({ @@ -128,16 +130,16 @@ export class EnhancedSettings { forceFresh = false, ): Promise< K extends "commands" - ? Command[] - : K extends "userSettings" - ? UserSettings - : K extends "stars" - ? Star[] - : K extends "shortcuts" - ? ShortcutSettings - : K extends "userStats" - ? UserStats - : any + ? Command[] + : K extends "userSettings" + ? UserSettings + : K extends "stars" + ? Star[] + : K extends "shortcuts" + ? ShortcutSettings + : K extends "userStats" + ? UserStats + : any > { if (section === CACHE_SECTIONS.COMMANDS) { let commands = await settingsCache.get( @@ -182,6 +184,7 @@ export class EnhancedSettings { hasShownReviewRequest: data.userStats.hasShownReviewRequest, hasDismissedPromptHistoryBanner: data.userStats.hasDismissedPromptHistoryBanner, + hasShownHubShareToast: data.userStats.hasShownHubShareToast, } as SettingsType } diff --git a/packages/extension/src/services/settings/settings.test.ts b/packages/extension/src/services/settings/settings.test.ts index b14910e3..39102bd3 100644 --- a/packages/extension/src/services/settings/settings.test.ts +++ b/packages/extension/src/services/settings/settings.test.ts @@ -193,6 +193,7 @@ describe("Settings", () => { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, stars: [] as Star[], } @@ -213,6 +214,7 @@ describe("Settings", () => { hasShownReviewRequest: mockSettings.hasShownReviewRequest, hasDismissedPromptHistoryBanner: mockSettings.hasDismissedPromptHistoryBanner, + hasShownHubShareToast: mockSettings.hasShownHubShareToast, }) expect(mockStorage.set).toHaveBeenCalledWith( STORAGE_KEY.SHORTCUTS, @@ -632,6 +634,7 @@ describe("migrate function", () => { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, stars: [] as Star[], } diff --git a/packages/extension/src/services/settings/settings.ts b/packages/extension/src/services/settings/settings.ts index f0ebd066..e70f8d69 100644 --- a/packages/extension/src/services/settings/settings.ts +++ b/packages/extension/src/services/settings/settings.ts @@ -137,6 +137,7 @@ export const Settings = { commandExecutionCount, hasShownReviewRequest, hasDismissedPromptHistoryBanner, + hasShownHubShareToast, stars, ...restData } = data @@ -145,6 +146,7 @@ export const Settings = { commandExecutionCount, hasShownReviewRequest, hasDismissedPromptHistoryBanner, + hasShownHubShareToast, } ps.push(Storage.set(STORAGE_KEY.USER_STATS, userStats)) diff --git a/packages/extension/src/services/storage/index.ts b/packages/extension/src/services/storage/index.ts index 52f1540b..713d05c7 100644 --- a/packages/extension/src/services/storage/index.ts +++ b/packages/extension/src/services/storage/index.ts @@ -57,6 +57,7 @@ const DEFAULTS = { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, }, [STORAGE_KEY.SHORTCUTS]: { shortcuts: [], diff --git a/packages/extension/src/types/index.ts b/packages/extension/src/types/index.ts index afc312b0..2fa99431 100644 --- a/packages/extension/src/types/index.ts +++ b/packages/extension/src/types/index.ts @@ -180,6 +180,7 @@ export type UserStats = { commandExecutionCount: number hasShownReviewRequest: boolean hasDismissedPromptHistoryBanner: boolean + hasShownHubShareToast: boolean } export type ShortcutCommand = {