From b7c670048382bbec732856f74fdb85f0fe79c523 Mon Sep 17 00:00:00 2001 From: lshw54 Date: Mon, 13 Jul 2026 13:02:01 +0800 Subject: [PATCH] fix(announcement): key the forced read on an announcement ID, not the app version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updating the app used to re-force the 30-second announcement read even when the announcement itself had not changed: the seen state stored the acknowledged APP VERSION, so every release invalidated it. Rework the identity to a content revision string: - src/constants/announcement.ts (new): ANNOUNCEMENT_ID ('YYYY-MM-description'), ANNOUNCEMENT_FORCED_SECONDS, the two card URLs, and the isAnnouncementSeenValue() predicate. Publishing a new announcement = bump the ID (one edit) -> resets everyone's seen state; app updates alone never re-force an unchanged notice. - AnnouncementModal.vue: compare/persist ANNOUNCEMENT_ID instead of the app version (commands.version() no longer needed); same key 'announcementSeenVersion' so legacy acknowledgements stay readable. - Migration: values written by pre-ID builds are app versions ('6.0.5'); everyone who acknowledged one read the current issue-#323 notice, so version-shaped values count as seen WHILE the shipped ID is still the inaugural one — the clause self-deactivates on the first real ID bump (no innocent re-force from the mechanism change itself). - Tests: modal specs moved to the ID mechanism + legacy-value and bumped-ID cases; new predicate spec (7 new tests, 646 total). --- src/components/AnnouncementModal.vue | 98 +++++++++++-------- src/constants/announcement.ts | 67 +++++++++++++ .../unit/components/AnnouncementModal.spec.ts | 58 +++++++---- tests/unit/constants/announcement.spec.ts | 44 +++++++++ 4 files changed, 205 insertions(+), 62 deletions(-) create mode 100644 src/constants/announcement.ts create mode 100644 tests/unit/constants/announcement.spec.ts diff --git a/src/components/AnnouncementModal.vue b/src/components/AnnouncementModal.vue index f6cb59a..2719d96 100644 --- a/src/components/AnnouncementModal.vue +++ b/src/components/AnnouncementModal.vue @@ -4,23 +4,31 @@ * * # Behaviour (per the request) * - * - **Per-update, first-launch forced read.** On first launch after an - * update (current app version ≠ the version stored in Config.xml under - * {@link SEEN_KEY}) the notice pops automatically and its only button is - * disabled for a {@link READ_SECONDS}-second countdown, after which it - * becomes "read + don't show again" and persists the version. + * - **Per-announcement forced read.** On first launch where the stored + * acknowledgement differs from {@link ANNOUNCEMENT_ID} (a content + * revision string, NOT the app version — see + * `src/constants/announcement.ts`) the notice pops automatically and + * its only button is disabled for an + * {@link ANNOUNCEMENT_FORCED_SECONDS}-second countdown, after which it + * becomes "read + don't show again" and persists the ID. Updating the + * app without bumping the ID therefore never re-forces the read; + * publishing a new announcement (bumping the ID) forces it exactly + * once for everyone. * - **Bigger window while shown.** The app's window is normally sized for a * single form (e.g. the login page); showing the notice temporarily grows * the OS window so the announcement isn't crammed, and restores the * previous size on close. * - **Re-openable afterwards, permanently.** Once acknowledged (or on any - * later launch of the same version) a slim full-width banner sits just - * under the title bar so the user can re-open and re-read the notice at - * will — always without the forced countdown. The banner is permanent - * (no dismiss) and reserves its own strip so it never overlaps the page. - * - **Robust "seen" state.** The acknowledged version is stored in both + * later launch of the same announcement) a slim full-width banner sits + * just under the title bar so the user can re-open and re-read the + * notice at will — always without the forced countdown. The banner is + * permanent (no dismiss) and reserves its own strip so it never + * overlaps the page. + * - **Robust "seen" state.** The acknowledged ID is stored in both * Config.xml and WebView localStorage; either one counts as seen, so - * hand-wiping one store doesn't re-trigger the forced read. + * hand-wiping one store doesn't re-trigger the forced read. Values + * written by pre-ID builds (app versions like `"6.0.5"`) still count + * as seen for the current notice — see `isAnnouncementSeenValue`. * * Mounted once at the app root ({@link App.vue}) so it overlays whatever * route is active. @@ -35,21 +43,26 @@ import { commands } from '../types/bindings' import { safeInvoke } from '../services/invoke' import { setWindowFitSuspended } from '../services/windowFit' import { useConfigStore } from '../stores/config' +import { + ANNOUNCEMENT_FORCED_SECONDS, + ANNOUNCEMENT_ID, + ANNOUNCEMENT_MAPLELINK_URL, + ANNOUNCEMENT_MORE_INFO_URL, + isAnnouncementSeenValue, +} from '../constants/announcement' -/** Seconds the user must wait before the dismiss button enables. */ -const READ_SECONDS = 30 /** - * Key holding the last app version the user acknowledged. Written to - * BOTH Config.xml and WebView localStorage; "seen" if *either* matches - * (see {@link isSeen}). Two independent stores means hand-deleting one - * (e.g. editing Config.xml) doesn't silently force the forced-read again. + * Key holding the acknowledged announcement ID. Written to BOTH + * Config.xml and WebView localStorage; "seen" if *either* matches (see + * {@link isSeen}). Two independent stores means hand-deleting one + * (e.g. editing Config.xml) doesn't silently force the forced-read + * again. The key name predates the ID mechanism (it used to hold the + * app version) and is kept so legacy acknowledgements stay readable. */ const SEEN_KEY = 'announcementSeenVersion' /** Logical size the window grows to while the notice is shown. */ const BIG_W = 640 const BIG_H = 720 -const MAPLELINK_URL = 'https://github.com/lshw54/maplelink' -const ISSUE_323_URL = 'https://github.com/pungin/Beanfun/issues/323' defineOptions({ name: 'AnnouncementModal' }) @@ -86,9 +99,8 @@ watch( ) /** `true` = the auto-shown, countdown-gated first read; `false` = review. */ const forced = ref(false) -const remaining = ref(READ_SECONDS) +const remaining = ref(ANNOUNCEMENT_FORCED_SECONDS) let timer: ReturnType | null = null -let appVersion = '' // Window size captured before growing, restored on close. let savedSize: Awaited['innerSize']>> | null = null @@ -124,33 +136,34 @@ async function restoreWindow(): Promise { } function isSeen(): boolean { - if (config.get(SEEN_KEY) === appVersion) return true + if (isAnnouncementSeenValue(config.get(SEEN_KEY))) return true try { - return localStorage.getItem(SEEN_KEY) === appVersion + return isAnnouncementSeenValue(localStorage.getItem(SEEN_KEY)) } catch { return false } } /** - * Record the current version as acknowledged in BOTH stores so the - * forced read is never repeated for this version — and stays that way - * even if one store is later wiped by hand. Only the next app *update* - * (a new {@link appVersion}) brings the notice back. + * Record the current announcement as acknowledged in BOTH stores so the + * forced read is never repeated for this notice — and stays that way + * even if one store is later wiped by hand. Only publishing a *new* + * announcement (bumping {@link ANNOUNCEMENT_ID}) brings it back; app + * updates alone never do. */ async function markSeen(): Promise { try { - localStorage.setItem(SEEN_KEY, appVersion) + localStorage.setItem(SEEN_KEY, ANNOUNCEMENT_ID) } catch { /* localStorage unavailable — Config.xml below still records it */ } - await config.set(SEEN_KEY, appVersion) + await config.set(SEEN_KEY, ANNOUNCEMENT_ID) } async function openForced(): Promise { visible.value = true forced.value = true - remaining.value = READ_SECONDS + remaining.value = ANNOUNCEMENT_FORCED_SECONDS await growWindow() stopTimer() timer = setInterval(() => { @@ -210,18 +223,9 @@ function waitForConfigLoaded(timeoutMs = 5000): Promise { } onMounted(async () => { - // `commands.version` never returns a Result — a throw is an IPC-bridge - // failure, in which case we simply don't show anything (non-critical). - try { - const info = await commands.version() - appVersion = info.app - } catch { - return - } - if (!appVersion) return // Wait for Config.xml before the seen-check, or a still-empty cache - // makes an already-acknowledged version look unseen and re-forces the - // 30-second read every launch. See {@link waitForConfigLoaded}. + // makes an already-acknowledged announcement look unseen and re-forces + // the 30-second read every launch. See {@link waitForConfigLoaded}. await waitForConfigLoaded() ready.value = true if (!isSeen()) await openForced() @@ -266,10 +270,18 @@ async function open(url: string): Promise { diff --git a/src/constants/announcement.ts b/src/constants/announcement.ts new file mode 100644 index 0000000..492780c --- /dev/null +++ b/src/constants/announcement.ts @@ -0,0 +1,67 @@ +/** + * Announcement identity & publish knobs (issue #323 notice; mechanism + * reworked so app updates alone never re-force the read). + * + * # How the forced announcement works + * + * - The forced notice is identified by {@link ANNOUNCEMENT_ID}, a plain + * revision string in `"YYYY-MM-description"` form — **not** the app + * version. Acknowledging the notice stores this ID (Config.xml + + * localStorage under `announcementSeenVersion`). + * - On boot, `AnnouncementModal.vue` compares the stored value against + * {@link ANNOUNCEMENT_ID}: equal → nothing pops; different → the + * notice auto-opens with the {@link ANNOUNCEMENT_FORCED_SECONDS} + * forced-read countdown. + * - So updating the app does NOT re-force an unchanged announcement, + * and publishing a new announcement is exactly one edit here: + * + * # Publishing a new announcement + * + * 1. Bump {@link ANNOUNCEMENT_ID} to a new unique string + * (`"2026-09-whatever"`) — this resets every user's seen state. + * 2. Update the `announcement.*` strings for all three locales in + * `src/i18n/messages.ts` (the key sets must stay identical across + * locales — a property test enforces it). + * 3. Adjust {@link ANNOUNCEMENT_FORCED_SECONDS} / the URLs below only + * if the new notice needs it. + */ + +/** + * Revision ID of the announcement currently shipped. Bumping this (and + * only this) re-triggers the forced read for every user. + */ +export const ANNOUNCEMENT_ID = '2026-07-dual-line-development-notice' + +/** + * The ID the pre-ID mechanism maps to. Before this mechanism landed the + * seen key stored the acknowledged **app version** (`"6.0.5"`, …), and + * everyone who acknowledged any of those versions read the issue-#323 + * dual-line notice — the only announcement that existed. While + * {@link ANNOUNCEMENT_ID} still identifies that same notice, a legacy + * version-shaped value therefore counts as seen (no innocent re-force + * on update); as soon as the ID is bumped to a *new* announcement the + * clause self-deactivates and legacy users are forced like everyone + * else. Do not update this constant when bumping the ID. + */ +export const LEGACY_VERSION_VALUES_MEAN_ID = '2026-07-dual-line-development-notice' + +/** Seconds the user must wait before the forced notice can be dismissed. */ +export const ANNOUNCEMENT_FORCED_SECONDS = 30 + +/** External links rendered inside the notice card. */ +export const ANNOUNCEMENT_MAPLELINK_URL = 'https://github.com/lshw54/maplelink' +export const ANNOUNCEMENT_MORE_INFO_URL = 'https://github.com/pungin/Beanfun/issues/323' + +/** + * `true` when a stored seen-value acknowledges the **current** + * announcement — either the ID itself, or (only while the current ID is + * still {@link LEGACY_VERSION_VALUES_MEAN_ID}) a legacy app-version + * value written by builds that predate the ID mechanism. + */ +export function isAnnouncementSeenValue(stored: string | null | undefined): boolean { + if (!stored) return false + if (stored === ANNOUNCEMENT_ID) return true + return ( + ANNOUNCEMENT_ID === LEGACY_VERSION_VALUES_MEAN_ID && /^\d+\.\d+(\.\d+)*$/.test(stored.trim()) + ) +} diff --git a/tests/unit/components/AnnouncementModal.spec.ts b/tests/unit/components/AnnouncementModal.spec.ts index f9ceef1..70961d2 100644 --- a/tests/unit/components/AnnouncementModal.spec.ts +++ b/tests/unit/components/AnnouncementModal.spec.ts @@ -19,18 +19,17 @@ vi.mock('../../../src/types/bindings', () => ({ import { commands } from '../../../src/types/bindings' import { createAppI18n } from '../../../src/i18n' import { useConfigStore } from '../../../src/stores/config' +import { ANNOUNCEMENT_ID, ANNOUNCEMENT_FORCED_SECONDS } from '../../../src/constants/announcement' import AnnouncementModal from '../../../src/components/AnnouncementModal.vue' const ok = (data: T): Promise> => Promise.resolve({ status: 'ok', data }) -const mockVersion = vi.mocked(commands.version) const mockOpenUrl = vi.mocked(commands.openUrl) const mockSetConfig = vi.mocked(commands.setConfig) const SEEN_KEY = 'announcementSeenVersion' -/** Forced-read countdown in ms — mirrors READ_SECONDS (30) in the SUT. */ -const READ_MS = 30_000 -const VERSION = { app: '6.0.3', tauri: '2.0.0' } as Awaited> +/** Forced-read countdown in ms. */ +const READ_MS = ANNOUNCEMENT_FORCED_SECONDS * 1000 let pinia: ReturnType @@ -53,7 +52,6 @@ describe('AnnouncementModal', () => { // starts from an unacknowledged state. localStorage.clear() for (const fn of Object.values(commands) as ReturnType[]) fn.mockReset() - mockVersion.mockResolvedValue(VERSION) mockOpenUrl.mockReturnValue(ok(null)) mockSetConfig.mockReturnValue(ok(null)) }) @@ -62,7 +60,7 @@ describe('AnnouncementModal', () => { vi.useRealTimers() }) - it('shows on first launch when the version has not been acknowledged', async () => { + it('shows on first launch when the announcement has not been acknowledged', async () => { const wrapper = mountModal() await flushPromises() expect(wrapper.find('[data-testid="announcement"]').exists()).toBe(true) @@ -70,7 +68,7 @@ describe('AnnouncementModal', () => { it('does not auto-show when acknowledged, but offers the re-open chip', async () => { const config = useConfigStore() - config.entries[SEEN_KEY] = VERSION.app + config.entries[SEEN_KEY] = ANNOUNCEMENT_ID const wrapper = mountModal() await flushPromises() expect(wrapper.find('[data-testid="announcement"]').exists()).toBe(false) @@ -79,7 +77,7 @@ describe('AnnouncementModal', () => { it('re-opens in review mode (no countdown) when the chip is clicked', async () => { const config = useConfigStore() - config.entries[SEEN_KEY] = VERSION.app + config.entries[SEEN_KEY] = ANNOUNCEMENT_ID const wrapper = mountModal() await flushPromises() @@ -102,7 +100,7 @@ describe('AnnouncementModal', () => { it('keeps the re-open banner permanent (no session-hide control)', async () => { const config = useConfigStore() - config.entries[SEEN_KEY] = VERSION.app + config.entries[SEEN_KEY] = ANNOUNCEMENT_ID const wrapper = mountModal() await flushPromises() @@ -111,11 +109,33 @@ describe('AnnouncementModal', () => { expect(wrapper.find('[data-testid="announcement-banner-hide"]').exists()).toBe(false) }) - it('treats the version as seen when only localStorage records it (Config.xml wiped)', async () => { + it('treats a legacy app-version value as seen (pre-ID builds, no re-force on update)', async () => { + // Builds before the ID mechanism stored the acknowledged APP VERSION. + // Everyone who acknowledged any of those versions read the current + // (issue #323) notice, so upgrading must not force them again. + const config = useConfigStore() + config.entries[SEEN_KEY] = '6.0.5' + const wrapper = mountModal() + await flushPromises() + expect(wrapper.find('[data-testid="announcement"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="announcement-banner"]').exists()).toBe(true) + }) + + it('forces the read when the stored value is a different announcement ID', async () => { + // A previously published (hypothetical) announcement was read, but + // the shipped ID has since been bumped — the new notice must force. + const config = useConfigStore() + config.entries[SEEN_KEY] = '2020-01-some-older-announcement' + const wrapper = mountModal() + await flushPromises() + expect(wrapper.find('[data-testid="announcement"]').exists()).toBe(true) + }) + + it('treats the announcement as seen when only localStorage records it (Config.xml wiped)', async () => { // Config.xml has no seen entry, but localStorage does — either store // alone must suppress the forced read so hand-editing one file can't // re-trigger the countdown on the next launch. - localStorage.setItem(SEEN_KEY, VERSION.app) + localStorage.setItem(SEEN_KEY, ANNOUNCEMENT_ID) const wrapper = mountModal() await flushPromises() @@ -123,7 +143,7 @@ describe('AnnouncementModal', () => { expect(wrapper.find('[data-testid="announcement-banner"]').exists()).toBe(true) }) - it('mirrors the acknowledged version into localStorage on forced dismiss', async () => { + it('mirrors the acknowledged ID into localStorage on forced dismiss', async () => { const wrapper = mountModal() await flushPromises() @@ -132,7 +152,7 @@ describe('AnnouncementModal', () => { await wrapper.get('[data-testid="announcement-dismiss"]').trigger('click') await flushPromises() - expect(localStorage.getItem(SEEN_KEY)).toBe(VERSION.app) + expect(localStorage.getItem(SEEN_KEY)).toBe(ANNOUNCEMENT_ID) }) it('disables dismiss during the forced-read countdown, then enables it', async () => { @@ -146,7 +166,7 @@ describe('AnnouncementModal', () => { expect((btn().element as HTMLButtonElement).disabled).toBe(false) }) - it('persists the version and hides on dismiss (after the countdown)', async () => { + it('persists the ID and hides on dismiss (after the countdown)', async () => { const wrapper = mountModal() await flushPromises() @@ -155,7 +175,7 @@ describe('AnnouncementModal', () => { await wrapper.get('[data-testid="announcement-dismiss"]').trigger('click') await flushPromises() - expect(commands.setConfig).toHaveBeenCalledWith(SEEN_KEY, VERSION.app) + expect(commands.setConfig).toHaveBeenCalledWith(SEEN_KEY, ANNOUNCEMENT_ID) expect(wrapper.find('[data-testid="announcement"]').exists()).toBe(false) }) @@ -172,8 +192,8 @@ describe('AnnouncementModal', () => { it('waits for Config.xml to load before the seen-check (no premature forced read)', async () => { // Regression: the seen-check used to run on mount before the config - // cache was populated, so an already-acknowledged version read as - // unseen and re-forced the 30s read on every launch. + // cache was populated, so an already-acknowledged announcement read + // as unseen and re-forced the 30s read on every launch. const config = useConfigStore() config.loaded = false // still booting; cache is empty const wrapper = mountModal() @@ -181,8 +201,8 @@ describe('AnnouncementModal', () => { // Must not force the read while the cache is still loading. expect(wrapper.find('[data-testid="announcement"]').exists()).toBe(false) - // loadAll() resolves — the acknowledged version lands in the cache. - config.entries[SEEN_KEY] = VERSION.app + // loadAll() resolves — the acknowledged ID lands in the cache. + config.entries[SEEN_KEY] = ANNOUNCEMENT_ID config.loaded = true await flushPromises() diff --git a/tests/unit/constants/announcement.spec.ts b/tests/unit/constants/announcement.spec.ts new file mode 100644 index 0000000..a6a61a0 --- /dev/null +++ b/tests/unit/constants/announcement.spec.ts @@ -0,0 +1,44 @@ +/** + * Specs for the announcement seen-value predicate — the piece that + * decides whether the forced read fires (see + * src/constants/announcement.ts for the mechanism). + */ + +import { describe, expect, it } from 'vitest' +import { + ANNOUNCEMENT_ID, + LEGACY_VERSION_VALUES_MEAN_ID, + isAnnouncementSeenValue, +} from '../../../src/constants/announcement' + +describe('isAnnouncementSeenValue', () => { + it('accepts the current announcement ID', () => { + expect(isAnnouncementSeenValue(ANNOUNCEMENT_ID)).toBe(true) + }) + + it('rejects empty / missing values', () => { + expect(isAnnouncementSeenValue('')).toBe(false) + expect(isAnnouncementSeenValue(null)).toBe(false) + expect(isAnnouncementSeenValue(undefined)).toBe(false) + }) + + it('rejects a different announcement ID', () => { + expect(isAnnouncementSeenValue('2020-01-some-older-announcement')).toBe(false) + }) + + it('accepts legacy app-version values while the inaugural ID is shipped', () => { + // Pre-ID builds stored the acknowledged app version; those users read + // the current (issue #323) notice and must not be re-forced by the + // mechanism change itself. + expect(ANNOUNCEMENT_ID).toBe(LEGACY_VERSION_VALUES_MEAN_ID) + expect(isAnnouncementSeenValue('6.0.5')).toBe(true) + expect(isAnnouncementSeenValue('6.0.5.2607110250')).toBe(true) + expect(isAnnouncementSeenValue('6.0.3')).toBe(true) + }) + + it('rejects values that merely resemble versions loosely', () => { + expect(isAnnouncementSeenValue('6')).toBe(false) + expect(isAnnouncementSeenValue('v6.0.5')).toBe(false) + expect(isAnnouncementSeenValue('6.0.5-beta')).toBe(false) + }) +})