diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 11a5c16..fd4bfab 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -255,6 +255,7 @@ const DEFAULT_CPA_CALLBACK_MODE = 'step8'; const MAIL_2925_MODE_PROVIDE = 'provide'; const MAIL_2925_MODE_RECEIVE = 'receive'; const DEFAULT_MAIL_2925_MODE = MAIL_2925_MODE_PROVIDE; +const NEW_USER_GUIDE_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-new-user-guide-prompt-dismissed'; const AUTO_SKIP_FAILURES_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-auto-skip-failures-prompt-dismissed'; const AUTO_RUN_FALLBACK_RISK_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-auto-run-fallback-risk-prompt-dismissed'; const CONTRIBUTION_CONTENT_PROMPT_DISMISSED_VERSION_STORAGE_KEY = 'multipage-contribution-content-prompt-dismissed-version'; @@ -876,6 +877,59 @@ function setPromptDismissed(storageKey, dismissed) { } } +function isNewUserGuidePromptDismissed() { + return isPromptDismissed(NEW_USER_GUIDE_PROMPT_DISMISSED_STORAGE_KEY); +} + +function setNewUserGuidePromptDismissed(dismissed) { + setPromptDismissed(NEW_USER_GUIDE_PROMPT_DISMISSED_STORAGE_KEY, dismissed); +} + +function shouldPromptNewUserGuide() { + if (isNewUserGuidePromptDismissed()) { + return false; + } + if (!btnContributionMode || btnContributionMode.disabled) { + return false; + } + if (latestState?.contributionMode) { + return false; + } + return true; +} + +function getContributionPortalUrl() { + return String(contributionContentService?.portalUrl || 'https://apikey.qzz.io').trim(); +} + +function openNewUserGuidePrompt() { + return openActionModal({ + title: '新手引导', + message: '如果你是第一次使用,可以先查看贡献页里的公告和使用教程。点击“查看引导”会自动打开贡献页面。', + alert: { + text: '本提示仅出现一次。', + }, + actions: [ + { id: null, label: '取消', variant: 'btn-ghost' }, + { id: 'confirm', label: '查看引导', variant: 'btn-primary' }, + ], + }); +} + +async function maybeShowNewUserGuidePrompt() { + if (!shouldPromptNewUserGuide()) { + return false; + } + + setNewUserGuidePromptDismissed(true); + const choice = await openNewUserGuidePrompt(); + if (choice === 'confirm') { + openExternalUrl(getContributionPortalUrl()); + return true; + } + return false; +} + function getDismissedContributionContentPromptVersion() { return String(localStorage.getItem(CONTRIBUTION_CONTENT_PROMPT_DISMISSED_VERSION_STORAGE_KEY) || '').trim(); } @@ -4948,7 +5002,12 @@ restoreState().then(() => { updatePanelModeUI(); updateButtonStates(); updateStatusDisplay(latestState); - return refreshContributionContentHint(); + return refreshContributionContentHint() + .catch((error) => { + console.warn('Failed to refresh contribution content hint during initialization:', error); + return null; + }) + .then(() => maybeShowNewUserGuidePrompt()); }).catch((err) => { console.error('Failed to initialize sidepanel state:', err); }); diff --git a/tests/sidepanel-new-user-guide.test.js b/tests/sidepanel-new-user-guide.test.js new file mode 100644 index 0000000..0c64a53 --- /dev/null +++ b/tests/sidepanel-new-user-guide.test.js @@ -0,0 +1,182 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +test('new user guide prompt is only eligible before the one-time dismissal is set', () => { + const bundle = [ + extractFunction('isPromptDismissed'), + extractFunction('setPromptDismissed'), + extractFunction('isNewUserGuidePromptDismissed'), + extractFunction('setNewUserGuidePromptDismissed'), + extractFunction('shouldPromptNewUserGuide'), + ].join('\n'); + + const api = new Function(` +const NEW_USER_GUIDE_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-new-user-guide-prompt-dismissed'; +const storage = new Map(); +const localStorage = { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + storage.set(key, String(value)); + }, + removeItem(key) { + storage.delete(key); + }, +}; +const btnContributionMode = { disabled: false }; +let latestState = { contributionMode: false }; +${bundle} +return { + shouldPromptNewUserGuide, + setDismissed(value) { + setNewUserGuidePromptDismissed(value); + }, + setButtonDisabled(value) { + btnContributionMode.disabled = Boolean(value); + }, + setContributionMode(value) { + latestState = { contributionMode: Boolean(value) }; + }, +}; +`)(); + + assert.equal(api.shouldPromptNewUserGuide(), true); + + api.setDismissed(true); + assert.equal(api.shouldPromptNewUserGuide(), false); + + api.setDismissed(false); + api.setButtonDisabled(true); + assert.equal(api.shouldPromptNewUserGuide(), false); + + api.setButtonDisabled(false); + api.setContributionMode(true); + assert.equal(api.shouldPromptNewUserGuide(), false); +}); + +test('new user guide prompt persists dismissal before awaiting the user choice and opens the contribution page on confirm', async () => { + const bundle = [ + extractFunction('isPromptDismissed'), + extractFunction('setPromptDismissed'), + extractFunction('isNewUserGuidePromptDismissed'), + extractFunction('setNewUserGuidePromptDismissed'), + extractFunction('shouldPromptNewUserGuide'), + extractFunction('getContributionPortalUrl'), + extractFunction('openNewUserGuidePrompt'), + extractFunction('maybeShowNewUserGuidePrompt'), + ].join('\n'); + + const api = new Function(` +const NEW_USER_GUIDE_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-new-user-guide-prompt-dismissed'; +const storage = new Map(); +const localStorage = { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + storage.set(key, String(value)); + }, + removeItem(key) { + storage.delete(key); + }, +}; +const btnContributionMode = { disabled: false }; +const latestState = { contributionMode: false }; +const contributionContentService = { portalUrl: 'https://apikey.qzz.io' }; +const openedUrls = []; +let modalOptions = null; +let nextChoice = 'confirm'; +function openExternalUrl(url) { + openedUrls.push(url); +} +function openActionModal(options) { + modalOptions = options; + return Promise.resolve(nextChoice); +} +${bundle} +return { + maybeShowNewUserGuidePrompt, + getDismissed() { + return localStorage.getItem(NEW_USER_GUIDE_PROMPT_DISMISSED_STORAGE_KEY); + }, + getOpenedUrls() { + return openedUrls.slice(); + }, + getModalOptions() { + return modalOptions; + }, + setNextChoice(choice) { + nextChoice = choice; + }, +}; +`)(); + + const confirmed = await api.maybeShowNewUserGuidePrompt(); + const modalOptions = api.getModalOptions(); + + assert.equal(confirmed, true); + assert.equal(api.getDismissed(), '1'); + assert.deepStrictEqual(api.getOpenedUrls(), ['https://apikey.qzz.io']); + assert.equal(modalOptions.title, '新手引导'); + assert.equal(modalOptions.alert.text, '本提示仅出现一次。'); + assert.deepStrictEqual( + modalOptions.actions.map((item) => ({ id: item.id, label: item.label })), + [ + { id: null, label: '取消' }, + { id: 'confirm', label: '查看引导' }, + ] + ); + + api.setNextChoice(null); + const skipped = await api.maybeShowNewUserGuidePrompt(); + assert.equal(skipped, false); + assert.deepStrictEqual(api.getOpenedUrls(), ['https://apikey.qzz.io']); +});