diff --git a/src/components/announcements/AnnouncementDialog.tsx b/src/components/announcements/AnnouncementDialog.tsx index 2496c6c7..a9bc0fcf 100644 --- a/src/components/announcements/AnnouncementDialog.tsx +++ b/src/components/announcements/AnnouncementDialog.tsx @@ -102,7 +102,8 @@ export function AnnouncementDialog({ audience }: { audience: AnnouncementAudienc }).filter( (announcement) => announcement.presentation !== "notification" && - announcement.presentation !== "banner", + announcement.presentation !== "banner" && + announcement.presentation !== "export", ); setPopupAspectRatio( parsedRemoteFeed.settings.aspectRatio ?? diff --git a/src/components/video-editor/ExportSettingsMenu.tsx b/src/components/video-editor/ExportSettingsMenu.tsx index dadf7e9c..c2c469fe 100644 --- a/src/components/video-editor/ExportSettingsMenu.tsx +++ b/src/components/video-editor/ExportSettingsMenu.tsx @@ -25,7 +25,6 @@ interface ExportSettingsMenuProps { mp4FrameRate: ExportMp4FrameRate; onMp4FrameRateChange?: (frameRate: ExportMp4FrameRate) => void; exportPipelineModel?: ExportPipelineModel; - onExportPipelineModelChange?: (pipelineModel: ExportPipelineModel) => void; experimentalNvidiaCudaExport?: boolean; onExperimentalNvidiaCudaExportChange?: (enabled: boolean) => void; nvidiaCudaExportAvailable?: boolean; @@ -54,7 +53,6 @@ export function ExportSettingsMenu({ mp4FrameRate, onMp4FrameRateChange, exportPipelineModel = "modern", - onExportPipelineModelChange, experimentalNvidiaCudaExport = false, onExperimentalNvidiaCudaExportChange, nvidiaCudaExportAvailable = false, @@ -281,69 +279,6 @@ export function ExportSettingsMenu({ ); })} -
- - {tSettings("export.pipelineTitle", "Pipeline")} - -
-
- {( - [ - { - value: "legacy", - label: tSettings("export.pipeline.legacy", "Legacy"), - }, - { - value: "modern", - label: tSettings("export.pipeline.modern", "Lightning (Beta)"), - }, - ] as const - ).map((option) => { - const isActive = exportPipelineModel === option.value; - return ( - - ); - })} -
-

- {isLegacyModel - ? tSettings( - "export.pipeline.legacyHint", - "Legacy uses the current stable WebCodecs export path.", - ) - : tSettings( - "export.pipeline.lightningHint", - "Lightning (Beta) automatically uses the fastest compatible backend and falls back when needed.", - )} -

{!isLegacyModel && nvidiaCudaExportAvailable ? (
diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts index 53e4bff2..497e5e49 100644 --- a/src/components/video-editor/editorPreferences.test.ts +++ b/src/components/video-editor/editorPreferences.test.ts @@ -97,6 +97,12 @@ describe("editorPreferences", () => { expect(DEFAULT_EDITOR_PREFERENCES.exportPipelineModel).toBe("modern"); }); + it("migrates hidden legacy pipeline preferences to Lightning", () => { + expect(normalizeEditorPreferences({ exportPipelineModel: "legacy" })).toMatchObject({ + exportPipelineModel: "modern", + }); + }); + it("bakes in the stronger split motion blur defaults", () => { expect(DEFAULT_EDITOR_PREFERENCES.zoomMotionBlurTuning).toMatchObject({ panVelocityThreshold: 0, diff --git a/src/components/video-editor/export/useEditorExportController.ts b/src/components/video-editor/export/useEditorExportController.ts index 60911c7d..ec6aa3f7 100644 --- a/src/components/video-editor/export/useEditorExportController.ts +++ b/src/components/video-editor/export/useEditorExportController.ts @@ -1,15 +1,14 @@ import type { RefObject } from "react"; -import { useCallback } from "react"; import type { useI18n } from "@/contexts/I18nContext"; import type { useVideoEditorAudio } from "../audio/useVideoEditorAudio"; import type { getSmokeExportConfig } from "../smokeExportConfig"; import type { useAppearanceState } from "../state/useAppearanceState"; import type { useTimelineState } from "../state/useTimelineState"; -import { openExternalLink, RECORDLY_ISSUES_URL } from "../TutorialHelp"; import type { CursorTelemetryPoint, SpeedRegion, ZoomRegion } from "../types"; import type { VideoPlaybackRef } from "../VideoPlayback"; import { useExportDialogActions } from "./useExportDialogActions"; import type { useExportDimensions } from "./useExportDimensions"; +import { useExportMessages } from "./useExportMessages"; import { useExportRunner } from "./useExportRunner"; import type { useExportSession } from "./useExportSession"; import type { useExportSettings } from "./useExportSettings"; @@ -91,12 +90,10 @@ export function useEditorExportController(input: Input) { session: input.session, settings: input.settings, }); - const openLightningIssues = useCallback(async () => { - await openExternalLink( - RECORDLY_ISSUES_URL, - input.t("editor.feedback.openFailed", "Failed to open link."), - ); - }, [input.t]); + const exportMessage = useExportMessages({ + t: input.t, + active: status.isLightningExportInProgress, + }); - return { dialogActions, status, openLightningIssues }; + return { dialogActions, status, exportMessage }; } diff --git a/src/components/video-editor/export/useExportMessages.test.ts b/src/components/video-editor/export/useExportMessages.test.ts new file mode 100644 index 00000000..2d815b19 --- /dev/null +++ b/src/components/video-editor/export/useExportMessages.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { buildExportMessageStream, type ExportMessage } from "./useExportMessages"; + +function message(id: string): ExportMessage { + return { id, text: id, durationSeconds: 6 }; +} + +describe("buildExportMessageStream", () => { + it("alternates built-in tips with streamed announcements", () => { + expect( + buildExportMessageStream( + [message("tip-1"), message("tip-2"), message("tip-3")], + [message("announcement-1"), message("announcement-2")], + ).map(({ id }) => id), + ).toEqual(["tip-1", "announcement-1", "tip-2", "announcement-2", "tip-3"]); + }); + + it("uses only built-in tips when the announcement feed is empty", () => { + expect( + buildExportMessageStream([message("tip-1"), message("tip-2")], []).map(({ id }) => id), + ).toEqual(["tip-1", "tip-2"]); + }); +}); diff --git a/src/components/video-editor/export/useExportMessages.ts b/src/components/video-editor/export/useExportMessages.ts new file mode 100644 index 00000000..f89d8152 --- /dev/null +++ b/src/components/video-editor/export/useExportMessages.ts @@ -0,0 +1,141 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { BUNDLED_ANNOUNCEMENT_FEED } from "@/content/announcements"; +import type { useI18n } from "@/contexts/I18nContext"; +import { + readAnnouncementImpressionCounts, + readDismissedAnnouncementIds, + recordAnnouncementImpression, +} from "@/lib/announcementState"; +import type { Announcement } from "@/lib/announcements"; +import { parseAnnouncementFeed, selectAnnouncements } from "@/lib/announcements"; + +const DEFAULT_MESSAGE_DURATION_SECONDS = 6; +const MAX_EXPORT_MESSAGE_LENGTH = 220; + +export type ExportMessage = { + id: string; + text: string; + durationSeconds: number; + announcementId?: string; +}; + +function compactMessage(value: string): string { + const compacted = value.replace(/\s+/g, " ").trim(); + if (compacted.length <= MAX_EXPORT_MESSAGE_LENGTH) return compacted; + return `${compacted.slice(0, MAX_EXPORT_MESSAGE_LENGTH - 1).trimEnd()}…`; +} + +export function buildExportMessageStream( + tips: ExportMessage[], + announcements: ExportMessage[], +): ExportMessage[] { + const stream: ExportMessage[] = []; + const length = Math.max(tips.length, announcements.length); + for (let index = 0; index < length; index += 1) { + if (tips[index]) stream.push(tips[index]); + if (announcements[index]) stream.push(announcements[index]); + } + return stream; +} + +function toExportMessage(announcement: Announcement): ExportMessage { + return { + id: `announcement:${announcement.id}`, + announcementId: announcement.id, + text: compactMessage(`${announcement.title} — ${announcement.body}`), + durationSeconds: announcement.displayDurationSeconds ?? DEFAULT_MESSAGE_DURATION_SECONDS, + }; +} + +export function useExportMessages({ + t, + active, +}: { + t: ReturnType["t"]; + active: boolean; +}): string | null { + const [announcementMessages, setAnnouncementMessages] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const countedThisSessionRef = useRef(new Set()); + const tips = useMemo( + () => [ + { + id: "tip:auto-zooms", + text: t( + "editor.exportTips.autoZooms", + "Tip: Turn off auto-applied zooms in settings", + ), + durationSeconds: DEFAULT_MESSAGE_DURATION_SECONDS, + }, + { + id: "tip:experimental-builds", + text: t( + "editor.exportTips.experimentalBuilds", + "Tip: Try experimental builds by turning on access in settings", + ), + durationSeconds: DEFAULT_MESSAGE_DURATION_SECONDS, + }, + { + id: "tip:cursor-appearance", + text: t( + "editor.exportTips.cursorAppearance", + "Tip: You can customise your cursor appearance", + ), + durationSeconds: DEFAULT_MESSAGE_DURATION_SECONDS, + }, + ], + [t], + ); + const messages = useMemo( + () => buildExportMessageStream(tips, announcementMessages), + [tips, announcementMessages], + ); + const current = messages[currentIndex % messages.length]; + + useEffect(() => { + let cancelled = false; + const loadExportAnnouncements = async () => { + const dismissedIds = new Set(readDismissedAnnouncementIds()); + const impressionCounts = readAnnouncementImpressionCounts(); + const [appVersion, remoteFeed] = await Promise.all([ + window.electronAPI.getAppVersion().catch(() => "0.0.0"), + window.electronAPI.getAnnouncements().catch(() => null), + ]); + if (cancelled) return; + setAnnouncementMessages( + selectAnnouncements({ + bundled: BUNDLED_ANNOUNCEMENT_FEED.announcements, + remote: parseAnnouncementFeed(remoteFeed).announcements, + dismissedIds, + impressionCounts, + appVersion, + audience: "editor", + }) + .filter((announcement) => announcement.presentation === "export") + .map(toExportMessage), + ); + }; + void loadExportAnnouncements(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!active || messages.length < 2 || !current) return; + const timeout = window.setTimeout( + () => setCurrentIndex((index) => (index + 1) % messages.length), + current.durationSeconds * 1_000, + ); + return () => window.clearTimeout(timeout); + }, [active, current, messages.length]); + + useEffect(() => { + const announcementId = active ? current?.announcementId : undefined; + if (!announcementId || countedThisSessionRef.current.has(announcementId)) return; + countedThisSessionRef.current.add(announcementId); + recordAnnouncementImpression(announcementId); + }, [active, current]); + + return active ? (current?.text ?? null) : null; +} diff --git a/src/components/video-editor/layout/EditorExportMenu.tsx b/src/components/video-editor/layout/EditorExportMenu.tsx index 64b247fd..b39a8521 100644 --- a/src/components/video-editor/layout/EditorExportMenu.tsx +++ b/src/components/video-editor/layout/EditorExportMenu.tsx @@ -28,7 +28,7 @@ type Props = { handleRetrySaveExport: () => void; handleStartExportFromDropdown: () => void; revealExportedFile: () => void; - openLightningIssues: () => void; + exportMessage: string | null; }; export function EditorExportMenu(props: Props) { @@ -48,7 +48,7 @@ export function EditorExportMenu(props: Props) { handleRetrySaveExport, handleStartExportFromDropdown, revealExportedFile, - openLightningIssues, + exportMessage, } = props; const { exportQuality, @@ -56,7 +56,6 @@ export function EditorExportMenu(props: Props) { exportEncodingMode, setExportEncodingMode, exportPipelineModel, - setExportPipelineModel, mp4FrameRate, setMp4FrameRate, exportFormat, @@ -123,18 +122,9 @@ export function EditorExportMenu(props: Props) {

{t("editor.exportStatus.renderingFile", "Rendering your file.")}

- {isLightningExportInProgress ? ( -

- PLEASE{" "} - - with Lightning export{" "} - + {isLightningExportInProgress && exportMessage ? ( +

+ {exportMessage}

) : null} {isLegacyExportInProgress ? ( @@ -269,7 +259,6 @@ export function EditorExportMenu(props: Props) { mp4FrameRate={mp4FrameRate} onMp4FrameRateChange={setMp4FrameRate} exportPipelineModel={exportPipelineModel} - onExportPipelineModelChange={setExportPipelineModel} experimentalNvidiaCudaExport={ experimentalNvidiaCudaExport && nvidiaCudaExportAvailable } diff --git a/src/components/video-editor/layout/EditorHeader.tsx b/src/components/video-editor/layout/EditorHeader.tsx index 76fd0cd6..927830d8 100644 --- a/src/components/video-editor/layout/EditorHeader.tsx +++ b/src/components/video-editor/layout/EditorHeader.tsx @@ -46,7 +46,7 @@ type Props = { handleRetrySaveExport: () => void; handleStartExportFromDropdown: () => void; revealExportedFile: () => void; - openLightningIssues: () => void; + exportMessage: string | null; }; export function EditorHeader(props: Props) { @@ -80,7 +80,7 @@ export function EditorHeader(props: Props) { handleRetrySaveExport, handleStartExportFromDropdown, revealExportedFile, - openLightningIssues, + exportMessage, } = props; const { isEditingProjectName, @@ -219,7 +219,7 @@ export function EditorHeader(props: Props) { handleRetrySaveExport={handleRetrySaveExport} handleStartExportFromDropdown={handleStartExportFromDropdown} revealExportedFile={revealExportedFile} - openLightningIssues={openLightningIssues} + exportMessage={exportMessage} />
diff --git a/src/components/video-editor/layout/EditorShell.tsx b/src/components/video-editor/layout/EditorShell.tsx index b2defeb3..e8592828 100644 --- a/src/components/video-editor/layout/EditorShell.tsx +++ b/src/components/video-editor/layout/EditorShell.tsx @@ -89,7 +89,7 @@ export function EditorShell(props: Props) { handleSelectAnnotation, handleAutoSuggestZoomsConsumed, } = editing; - const { dialogActions, status: exportStatus, openLightningIssues } = exportController; + const { dialogActions, status: exportStatus, exportMessage } = exportController; const editorDialogs = (
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 8e71bde7..1a098f40 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -197,11 +197,9 @@ export function normalizeExportBackendPreference(value: unknown): ExportBackendP return "auto"; } -export function normalizeExportPipelineModel(value: unknown): ExportPipelineModel { - if (value === "modern" || value === "legacy") { - return value; - } - +export function normalizeExportPipelineModel(_value: unknown): ExportPipelineModel { + // Legacy remains available to internal smoke/export routing, but persisted + // user selections migrate to the only pipeline exposed by the editor UI. return "modern"; } diff --git a/src/i18n/locales/de/editor.json b/src/i18n/locales/de/editor.json index 4fe144c2..294c2ce5 100644 --- a/src/i18n/locales/de/editor.json +++ b/src/i18n/locales/de/editor.json @@ -117,6 +117,11 @@ "discardChanges": "Änderungen verwerfen", "saveProject": "Projekt speichern" }, + "exportTips": { + "autoZooms": "Tipp: Automatisch angewendete Zooms können in den Einstellungen deaktiviert werden", + "experimentalBuilds": "Tipp: Aktiviere den Zugriff auf experimentelle Builds in den Einstellungen", + "cursorAppearance": "Tipp: Du kannst das Aussehen deines Cursors anpassen" + }, "account": { "title": "Konto", "comingSoon": "Konto demnächst verfügbar" }, "nativeCaptureUnavailable": { "title": "Es ist nichts kaputt, aber wir können kein animiertes Cursor-Overlay rendern.", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index ad5e2902..099393ba 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -118,6 +118,11 @@ "discardChanges": "Discard changes", "saveProject": "Save project" }, + "exportTips": { + "autoZooms": "Tip: Turn off auto-applied zooms in settings", + "experimentalBuilds": "Tip: Try experimental builds by turning on access in settings", + "cursorAppearance": "Tip: You can customise your cursor appearance" + }, "account": { "title": "Account", "comingSoon": "Account coming soon" }, "nativeCaptureUnavailable": { "title": "Nothing’s broken, but we won’t be able to render an animated cursor overlay.", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index a0ba68ce..c5da834b 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -118,6 +118,11 @@ "discardChanges": "Descartar cambios", "saveProject": "Guardar proyecto" }, + "exportTips": { + "autoZooms": "Consejo: Desactiva los zooms automáticos en la configuración", + "experimentalBuilds": "Consejo: Activa el acceso a versiones experimentales en la configuración", + "cursorAppearance": "Consejo: Puedes personalizar la apariencia del cursor" + }, "account": { "title": "Cuenta", "comingSoon": "Cuenta próximamente" }, "nativeCaptureUnavailable": { "title": "Nada está roto, pero no podremos renderizar una superposición de cursor animada.", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 4ab0b54b..08704aaa 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -118,6 +118,11 @@ "discardChanges": "Ignorer les modifications", "saveProject": "Enregistrer le projet" }, + "exportTips": { + "autoZooms": "Astuce : Désactivez les zooms automatiques dans les paramètres", + "experimentalBuilds": "Astuce : Activez l’accès aux versions expérimentales dans les paramètres", + "cursorAppearance": "Astuce : Vous pouvez personnaliser l’apparence du curseur" + }, "account": { "title": "Compte", "comingSoon": "Compte bientôt disponible" }, "nativeCaptureUnavailable": { "title": "Rien n'est cassé, mais nous ne pourrons pas afficher une superposition animée du curseur.", diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 4448951a..0576b03c 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -118,6 +118,11 @@ "discardChanges": "Ignora modifiche", "saveProject": "Salva progetto" }, + "exportTips": { + "autoZooms": "Suggerimento: Disattiva gli zoom applicati automaticamente nelle impostazioni", + "experimentalBuilds": "Suggerimento: Attiva l’accesso alle build sperimentali nelle impostazioni", + "cursorAppearance": "Suggerimento: Puoi personalizzare l’aspetto del cursore" + }, "account": { "title": "Account", "comingSoon": "Account in arrivo" }, "nativeCaptureUnavailable": { "title": "Niente è rotto, ma non sarà possibile renderizzare un overlay del cursore animato.", diff --git a/src/i18n/locales/ko/editor.json b/src/i18n/locales/ko/editor.json index 3a974ea2..ddc411b9 100644 --- a/src/i18n/locales/ko/editor.json +++ b/src/i18n/locales/ko/editor.json @@ -119,6 +119,11 @@ "discardChanges": "변경 사항 버리기", "saveProject": "프로젝트 저장" }, + "exportTips": { + "autoZooms": "팁: 설정에서 자동 적용 확대를 끌 수 있습니다", + "experimentalBuilds": "팁: 설정에서 실험적 빌드 액세스를 켜 보세요", + "cursorAppearance": "팁: 커서 모양을 사용자 지정할 수 있습니다" + }, "account": { "title": "계정", "comingSoon": "계정 기능 준비 중" }, "nativeCaptureUnavailable": { "title": "문제가 생긴 것은 아니지만, 애니메이션 커서 오버레이를 렌더링할 수 없습니다.", diff --git a/src/i18n/locales/nl/editor.json b/src/i18n/locales/nl/editor.json index 00cec3d1..9d9376c7 100644 --- a/src/i18n/locales/nl/editor.json +++ b/src/i18n/locales/nl/editor.json @@ -119,6 +119,11 @@ "discardChanges": "Wijzigingen negeren", "saveProject": "Project opslaan" }, + "exportTips": { + "autoZooms": "Tip: Schakel automatisch toegepaste zooms uit in de instellingen", + "experimentalBuilds": "Tip: Schakel toegang tot experimentele builds in via de instellingen", + "cursorAppearance": "Tip: Je kunt het uiterlijk van je cursor aanpassen" + }, "account": { "title": "Account", "comingSoon": "Account binnenkort beschikbaar" }, "nativeCaptureUnavailable": { "title": "Er is niets kapot, maar we kunnen geen geanimeerde cursor-overlay renderen.", diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 216ca338..1a376631 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -118,6 +118,11 @@ "discardChanges": "Descartar alterações", "saveProject": "Salvar projeto" }, + "exportTips": { + "autoZooms": "Dica: Desative os zooms aplicados automaticamente nas configurações", + "experimentalBuilds": "Dica: Ative o acesso a versões experimentais nas configurações", + "cursorAppearance": "Dica: Você pode personalizar a aparência do cursor" + }, "account": { "title": "Conta", "comingSoon": "Conta em breve" }, "nativeCaptureUnavailable": { "title": "Nada está quebrado, mas não poderemos renderizar uma sobreposição animada do cursor.", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 847d0a93..56c62e1c 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -118,6 +118,11 @@ "discardChanges": "Отменить изменения", "saveProject": "Сохранить проект" }, + "exportTips": { + "autoZooms": "Совет: Автоматические приближения можно отключить в настройках", + "experimentalBuilds": "Совет: Включите доступ к экспериментальным сборкам в настройках", + "cursorAppearance": "Совет: Внешний вид курсора можно настроить" + }, "account": { "title": "Учётная запись", "comingSoon": "Учётная запись скоро появится" }, "nativeCaptureUnavailable": { "title": "Всё в порядке, но мы не можем отобразить анимированное наложение курсора.", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 79487495..bb799ed1 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -118,6 +118,11 @@ "discardChanges": "放弃更改", "saveProject": "保存项目" }, + "exportTips": { + "autoZooms": "提示:可在设置中关闭自动应用的缩放", + "experimentalBuilds": "提示:可在设置中开启实验版本访问权限", + "cursorAppearance": "提示:你可以自定义光标外观" + }, "account": { "title": "账户", "comingSoon": "账户功能即将推出" }, "nativeCaptureUnavailable": { "title": "没有出错,但我们无法渲染动画光标叠加层。", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 9d49ee2f..0332aed1 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -118,6 +118,11 @@ "discardChanges": "捨棄變更", "saveProject": "儲存專案" }, + "exportTips": { + "autoZooms": "提示:可在設定中關閉自動套用的縮放", + "experimentalBuilds": "提示:可在設定中開啟實驗版本存取權", + "cursorAppearance": "提示:你可以自訂游標外觀" + }, "account": { "title": "帳號", "comingSoon": "帳號功能即將推出" }, "nativeCaptureUnavailable": { "title": "沒有出錯,但我們無法轉譯動畫游標覆蓋層。", diff --git a/src/lib/announcements.test.ts b/src/lib/announcements.test.ts index a616c924..573158dd 100644 --- a/src/lib/announcements.test.ts +++ b/src/lib/announcements.test.ts @@ -116,6 +116,33 @@ describe("parseAnnouncementFeed", () => { ]); }); + it("accepts text-only messages for the export status stream", () => { + const feed = parseAnnouncementFeed({ + announcements: [ + { + id: "export-tip-1", + title: "Did you know?", + body: "Cursor styles can be changed in the editor.", + presentation: "export", + media: { type: "image", url: "https://example.com/ignored.jpg" }, + displayDurationSeconds: 8, + }, + ], + }); + + expect(feed.announcements).toEqual([ + { + id: "export-tip-1", + title: "Did you know?", + body: "Cursor styles can be changed in the editor.", + presentation: "export", + audience: "all", + priority: 0, + displayDurationSeconds: 8, + }, + ]); + }); + it("accepts an action that opens a safe editor section", () => { const feed = parseAnnouncementFeed({ announcements: [ diff --git a/src/lib/announcements.ts b/src/lib/announcements.ts index 9a65c9f6..4509ce14 100644 --- a/src/lib/announcements.ts +++ b/src/lib/announcements.ts @@ -31,7 +31,7 @@ export interface Announcement { id: string; title: string; body: string; - presentation?: "popup" | "notification" | "banner"; + presentation?: "popup" | "notification" | "banner" | "export"; audience: AnnouncementAudience; priority: number; mediaMode?: "banner" | "cover"; @@ -203,10 +203,13 @@ function parseAnnouncement(value: unknown): Announcement | undefined { const audience = value.audience === "editor" ? "editor" : "all"; const presentation = - value.presentation === "notification" || value.presentation === "banner" + value.presentation === "notification" || + value.presentation === "banner" || + value.presentation === "export" ? value.presentation : undefined; - const isTextOnlyPresentation = presentation === "notification" || presentation === "banner"; + const isTextOnlyPresentation = + presentation === "notification" || presentation === "banner" || presentation === "export"; const priority = typeof value.priority === "number" && Number.isFinite(value.priority) ? Math.max(-100, Math.min(100, value.priority))