diff --git a/src/main/ipc/changelogIpc.ts b/src/main/ipc/changelogIpc.ts new file mode 100644 index 000000000..c3d3916fc --- /dev/null +++ b/src/main/ipc/changelogIpc.ts @@ -0,0 +1,7 @@ +import { createRPCController } from '../../shared/ipc/rpc'; +import { changelogService } from '../services/ChangelogService'; + +export const changelogController = createRPCController({ + getLatestEntry: async (args?: { version?: string }) => + changelogService.getLatestEntry(args?.version), +}); diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 4df4a2c31..a41fff2a1 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -27,10 +27,12 @@ import { ipcMain } from 'electron'; import { registerGitlabIpc } from './gitlabIpc'; import { registerPlainIpc } from './plainIpc'; import { registerForgejoIpc } from './forgejoIpc'; +import { changelogController } from './changelogIpc'; export const rpcRouter = createRPCRouter({ db: databaseController, appSettings: appSettingsController, + changelog: changelogController, }); export type RpcRouter = typeof rpcRouter; diff --git a/src/main/services/ChangelogService.ts b/src/main/services/ChangelogService.ts new file mode 100644 index 000000000..42545f862 --- /dev/null +++ b/src/main/services/ChangelogService.ts @@ -0,0 +1,349 @@ +import { + compareChangelogVersions, + EMDASH_CHANGELOG_API_URL, + EMDASH_CHANGELOG_URL, + normalizeChangelogVersion, + type ChangelogEntry, +} from '@shared/changelog'; +import { log } from '../lib/logger'; + +type ChangelogCandidate = { + version?: string | null; + title?: string | null; + summary?: string | null; + content?: string | null; + contentHtml?: string | null; + markdown?: string | null; + body?: string | null; + html?: string | null; + publishedAt?: string | null; + published_at?: string | null; + date?: string | null; + url?: string | null; + href?: string | null; +}; + +function firstString(...values: Array): string | undefined { + for (const value of values) { + if (typeof value !== 'string') continue; + const trimmed = value.trim(); + if (trimmed) return trimmed; + } + return undefined; +} + +function decodeHtmlEntities(input: string): string { + return input + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/'/gi, "'"); +} + +function stripTags(input: string): string { + return decodeHtmlEntities(input.replace(/<[^>]+>/g, ' ')) + .replace(/\s+/g, ' ') + .trim(); +} + +function htmlToMarkdown(html: string): string { + const withoutScripts = html + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, ''); + + const withLinks = withoutScripts.replace( + /]*href=(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi, + (_match, _quote, href: string, text: string) => { + const label = stripTags(text); + return label ? `[${label}](${href.trim()})` : ''; + } + ); + + const withFormatting = withLinks + .replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_match, _tag, text: string) => { + const content = stripTags(text); + return content ? `**${content}**` : ''; + }) + .replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_match, _tag, text: string) => { + const content = stripTags(text); + return content ? `*${content}*` : ''; + }) + .replace(/]*>([\s\S]*?)<\/code>/gi, (_match, text: string) => { + const content = stripTags(text); + return content ? `\`${content}\`` : ''; + }); + + const withHeadings = withFormatting + .replace(/]*>([\s\S]*?)<\/h1>/gi, '\n# $1\n') + .replace(/]*>([\s\S]*?)<\/h2>/gi, '\n## $1\n') + .replace(/]*>([\s\S]*?)<\/h3>/gi, '\n### $1\n') + .replace(/]*>([\s\S]*?)<\/h4>/gi, '\n#### $1\n') + .replace(/]*>([\s\S]*?)<\/h5>/gi, '\n##### $1\n') + .replace(/]*>([\s\S]*?)<\/h6>/gi, '\n###### $1\n'); + + const withLists = withHeadings + .replace(/]*>([\s\S]*?)<\/li>/gi, '\n- $1') + .replace(/<\/(ul|ol)>/gi, '\n') + .replace(/<(ul|ol)\b[^>]*>/gi, '\n'); + + const withParagraphs = withLists + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/]*>/gi, '') + .replace(/<\/div>/gi, '\n') + .replace(/]*>/gi, '\n'); + + return decodeHtmlEntities(withParagraphs.replace(/<[^>]+>/g, ' ')) + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .replace(/ {2,}/g, ' ') + .trim(); +} + +function extractSummaryFromContent(content: string): string { + return ( + content + .split(/\n{2,}/) + .map((block) => block.trim()) + .find((block) => block && !block.startsWith('#') && !block.startsWith('- ')) ?? '' + ); +} + +function removeDuplicateTitle(content: string, title: string): string { + const normalizedTitle = title.trim().toLowerCase(); + const lines = content.split('\n'); + + while (lines.length > 0) { + const line = lines[0].trim(); + if (!line) { + lines.shift(); + continue; + } + + const normalizedLine = line + .replace(/^#+\s*/, '') + .trim() + .toLowerCase(); + if (normalizedLine === normalizedTitle) { + lines.shift(); + continue; + } + + break; + } + + return lines.join('\n').trim(); +} + +function normalizeEntry( + candidate: ChangelogCandidate, + requestedVersion?: string +): ChangelogEntry | null { + const version = normalizeChangelogVersion( + firstString(candidate.version, requestedVersion, extractVersion(candidate.title)) + ); + if (!version) return null; + + const title = firstString(candidate.title) ?? `What's new in Emdash v${version}`; + const contentSource = + firstString(candidate.content, candidate.markdown, candidate.body) ?? + (firstString(candidate.contentHtml, candidate.html) + ? htmlToMarkdown(firstString(candidate.contentHtml, candidate.html)!) + : ''); + + const dedupedContent = removeDuplicateTitle(contentSource, title); + const summary = + firstString(candidate.summary) ?? + extractSummaryFromContent(dedupedContent) ?? + `See what changed in Emdash v${version}.`; + + const content = dedupedContent || summary || `See what changed in Emdash v${version}.`; + + return { + version, + title, + summary, + content, + publishedAt: firstString(candidate.publishedAt, candidate.published_at, candidate.date), + url: firstString(candidate.url, candidate.href), + }; +} + +function extractVersion(input: string | null | undefined): string | undefined { + if (typeof input !== 'string') return undefined; + const match = input.match(/\bv?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b/); + return normalizeChangelogVersion(match?.[1] ?? null) ?? undefined; +} + +function pickBestCandidate( + candidates: ChangelogEntry[], + requestedVersion?: string +): ChangelogEntry | null { + if (candidates.length === 0) return null; + + const normalizedRequested = normalizeChangelogVersion(requestedVersion); + if (normalizedRequested) { + const exact = candidates.find((candidate) => candidate.version === normalizedRequested); + if (exact) return exact; + } + + return candidates + .slice() + .sort((left, right) => compareChangelogVersions(right.version, left.version))[0]; +} + +function extractCandidatesFromPayload(payload: unknown): ChangelogCandidate[] { + if (!payload || typeof payload !== 'object') return []; + + if (Array.isArray(payload)) { + return payload.filter((item): item is ChangelogCandidate => !!item && typeof item === 'object'); + } + + const record = payload as Record; + const directCandidate = normalizeEntry(record as ChangelogCandidate); + if (directCandidate) return [record as ChangelogCandidate]; + + const collections = ['entry', 'release', 'item', 'entries', 'items', 'releases', 'data']; + for (const key of collections) { + const value = record[key]; + if (Array.isArray(value)) { + return value.filter((item): item is ChangelogCandidate => !!item && typeof item === 'object'); + } + if (value && typeof value === 'object') { + return [value as ChangelogCandidate]; + } + } + + return []; +} + +async function fetchJson(url: string): Promise { + const response = await fetch(url, { + headers: { + Accept: 'application/json, text/plain;q=0.9, */*;q=0.8', + 'Cache-Control': 'no-cache', + }, + }); + + if (!response.ok) return null; + + const contentType = response.headers.get('content-type') ?? ''; + if (!contentType.includes('json')) return null; + + return response.json(); +} + +function extractTime(block: string): string | undefined { + const datetime = block.match(/]*datetime=(["'])(.*?)\1/i)?.[2]; + if (datetime?.trim()) return datetime.trim(); + + const timeContent = block.match(/]*>([\s\S]*?)<\/time>/i)?.[1]; + const normalized = stripTags(timeContent ?? ''); + return normalized || undefined; +} + +function extractTitle(block: string): string | undefined { + const heading = block.match(/]*>([\s\S]*?)<\/h[1-6]>/i)?.[1]; + const title = stripTags(heading ?? ''); + return title || undefined; +} + +function extractSummary(block: string): string | undefined { + const paragraph = block.match(/]*>([\s\S]*?)<\/p>/i)?.[1]; + const summary = stripTags(paragraph ?? ''); + return summary || undefined; +} + +export function parseChangelogHtml(html: string, requestedVersion?: string): ChangelogEntry | null { + const blocks = html.match(/<(article|section)\b[\s\S]*?<\/\1>/gi) ?? []; + const candidates: ChangelogEntry[] = []; + + for (const block of blocks) { + const versionFromBlock = normalizeChangelogVersion( + block.match(/data-version=(["'])(.*?)\1/i)?.[2] ?? + extractVersion(block) ?? + requestedVersion ?? + null + ); + if (!versionFromBlock) continue; + + const candidate = normalizeEntry( + { + version: versionFromBlock, + title: extractTitle(block), + summary: extractSummary(block), + contentHtml: block, + publishedAt: extractTime(block), + }, + requestedVersion + ); + + if (candidate) { + candidates.push(candidate); + } + } + + if (candidates.length > 0) { + return pickBestCandidate(candidates, requestedVersion); + } + + const fallback = normalizeEntry( + { + version: normalizeChangelogVersion(requestedVersion) ?? undefined, + title: extractTitle(html), + summary: extractSummary(html), + contentHtml: html, + publishedAt: extractTime(html), + }, + requestedVersion + ); + + return fallback; +} + +class ChangelogService { + async getLatestEntry(requestedVersion?: string): Promise { + const version = normalizeChangelogVersion(requestedVersion); + const apiUrls = [ + version + ? `${EMDASH_CHANGELOG_API_URL}?version=${encodeURIComponent(version)}` + : `${EMDASH_CHANGELOG_API_URL}?latest=1`, + version + ? `${EMDASH_CHANGELOG_URL}.json?version=${encodeURIComponent(version)}` + : `${EMDASH_CHANGELOG_URL}.json`, + ]; + + for (const url of apiUrls) { + try { + const payload = await fetchJson(url); + if (!payload) continue; + + const entries = extractCandidatesFromPayload(payload) + .map((candidate) => normalizeEntry(candidate, version ?? undefined)) + .filter((candidate): candidate is ChangelogEntry => candidate !== null); + const match = pickBestCandidate(entries, version ?? undefined); + if (match) return match; + } catch (error) { + log.debug('Changelog JSON fetch failed', { url, error }); + } + } + + try { + const response = await fetch(EMDASH_CHANGELOG_URL, { + headers: { Accept: 'text/html,application/xhtml+xml', 'Cache-Control': 'no-cache' }, + }); + if (!response.ok) return null; + const html = await response.text(); + return parseChangelogHtml(html, version ?? undefined); + } catch (error) { + log.error('Failed to fetch changelog HTML', error); + return null; + } + } +} + +export const changelogService = new ChangelogService(); diff --git a/src/main/settings.ts b/src/main/settings.ts index 54e1f57e7..beff22606 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -109,6 +109,9 @@ export interface AppSettings { }; defaultOpenInApp?: OpenInAppId; hiddenOpenInApps?: OpenInAppId[]; + changelog?: { + dismissedVersions: string[]; + }; } function getPlatformTaskSwitchDefaults(): { next: ShortcutBinding; prev: ShortcutBinding } { @@ -183,6 +186,9 @@ const DEFAULT_SETTINGS: AppSettings = { }, defaultOpenInApp: 'terminal', hiddenOpenInApps: [], + changelog: { + dismissedVersions: [], + }, }; function getSettingsPath(): string { @@ -543,6 +549,20 @@ export function normalizeSettings(input: AppSettings): AppSettings { out.hiddenOpenInApps = []; } + const rawDismissedVersions = (input as any)?.changelog?.dismissedVersions; + out.changelog = { + dismissedVersions: Array.isArray(rawDismissedVersions) + ? [ + ...new Set( + rawDismissedVersions + .filter((value: unknown): value is string => typeof value === 'string') + .map((value) => value.trim().replace(/^v/i, '')) + .filter(Boolean) + ), + ] + : [], + }; + return out; } diff --git a/src/renderer/components/ChangelogModal.tsx b/src/renderer/components/ChangelogModal.tsx new file mode 100644 index 000000000..91a533065 --- /dev/null +++ b/src/renderer/components/ChangelogModal.tsx @@ -0,0 +1,102 @@ +import { BaseModalProps } from '@/contexts/ModalProvider'; +import { MarkdownRenderer } from '@/components/ui/markdown-renderer'; +import { DialogContent } from '@/components/ui/dialog'; +import { EMDASH_CHANGELOG_URL, type ChangelogEntry } from '@shared/changelog'; +import { ExternalLink } from 'lucide-react'; + +interface ChangelogModalProps { + entry: ChangelogEntry; +} + +export function ChangelogModalOverlay({ entry }: BaseModalProps & ChangelogModalProps) { + return ; +} + +function formatPublishedAt(value?: string): string | null { + if (!value) return null; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: '2-digit', + year: 'numeric', + }).format(parsed); +} + +function normalizeLeadLine(value: string): string { + return value + .replace(/^#+\s*/, '') + .replace(/[^a-zA-Z0-9.]/g, '') + .replace(/\s+/g, '') + .trim() + .toLowerCase(); +} + +function stripLeadingReleaseHeadings(content: string, entry: ChangelogEntry): string { + const lines = content.split('\n'); + const normalizedVersion = normalizeLeadLine(`v${entry.version}`); + const normalizedTitle = normalizeLeadLine(entry.title); + const redundantLeads = new Set([ + normalizedVersion, + normalizeLeadLine(entry.version), + normalizeLeadLine("What's Changed"), + normalizeLeadLine(`v${entry.version} What's Changed`), + normalizeLeadLine(`${entry.version} What's Changed`), + normalizedTitle + normalizeLeadLine("What's Changed"), + ]); + + while (lines.length > 0) { + const line = lines[0].trim(); + if (!line) { + lines.shift(); + continue; + } + + const normalizedLine = normalizeLeadLine(line); + if (redundantLeads.has(normalizedLine) || normalizedLine === normalizedTitle) { + lines.shift(); + continue; + } + + break; + } + + return lines.join('\n').trim(); +} + +function ChangelogModal({ entry }: ChangelogModalProps): JSX.Element { + const publishedAt = formatPublishedAt(entry.publishedAt); + const content = stripLeadingReleaseHeadings(entry.content, entry); + + return ( + +
+ +
+ +
+ {publishedAt && ( +

+ {publishedAt} +

+ )} +

+ {entry.title} +

+ {entry.summary && ( +

{entry.summary}

+ )} +
+ +
+
+
+ ); +} diff --git a/src/renderer/components/UpdateModal.tsx b/src/renderer/components/UpdateModal.tsx index ccc157f3b..3735c54f3 100644 --- a/src/renderer/components/UpdateModal.tsx +++ b/src/renderer/components/UpdateModal.tsx @@ -14,8 +14,9 @@ import { AlertCircle, Loader2, } from 'lucide-react'; -import { useUpdater, EMDASH_RELEASES_URL } from '@/hooks/useUpdater'; +import { useUpdater } from '@/hooks/useUpdater'; import { BaseModalProps } from '@/contexts/ModalProvider'; +import { EMDASH_CHANGELOG_URL } from '@shared/changelog'; interface UpdateModalProps { onClose: () => void; @@ -65,7 +66,7 @@ function UpdateModal({ onClose }: UpdateModalProps): JSX.Element { Current version: v{appVersion || '...'} ·{' '} + + + + ); +} diff --git a/src/renderer/components/sidebar/LeftSidebar.tsx b/src/renderer/components/sidebar/LeftSidebar.tsx index cd993c8dd..6d4dbbe73 100644 --- a/src/renderer/components/sidebar/LeftSidebar.tsx +++ b/src/renderer/components/sidebar/LeftSidebar.tsx @@ -37,6 +37,9 @@ import { useTaskManagementContext } from '../../contexts/TaskManagementContext'; import { useAppSettings } from '../../contexts/AppSettingsProvider'; import { useLocalStorage } from '../../hooks/useLocalStorage'; import { ProjectsGroupLabel } from './ProjectsGroupLabel'; +import { useChangelogNotification } from '@/hooks/useChangelogNotification'; +import { useModalContext } from '@/contexts/ModalProvider'; +import { ChangelogNotificationCard } from './ChangelogNotificationCard'; const PROJECT_ORDER_KEY = 'sidebarProjectOrder'; @@ -91,6 +94,7 @@ export const LeftSidebar: React.FC = ({ onCloseSettingsPage, }) => { const { open, isMobile, setOpen } = useSidebar(); + const { showModal } = useModalContext(); const { projects, selectedProject, @@ -141,6 +145,10 @@ export const LeftSidebar: React.FC = ({ const { settings } = useAppSettings(); const taskHoverAction = settings?.interface?.taskHoverAction ?? 'delete'; + const changelogNotification = useChangelogNotification(); + const changelogEntry = changelogNotification.entry; + const changelogCardRef = useRef(null); + const [changelogCardHeight, setChangelogCardHeight] = useState(0); const [forceOpenIds, setForceOpenIds] = useState>(new Set()); const prevTaskCountsRef = useRef>(new Map()); @@ -169,6 +177,27 @@ export const LeftSidebar: React.FC = ({ [onCloseSettingsPage] ); + useEffect(() => { + const card = changelogCardRef.current; + if (!card || !changelogNotification.isVisible || !changelogEntry) { + setChangelogCardHeight(0); + return; + } + + const updateHeight = () => { + setChangelogCardHeight(card.getBoundingClientRect().height); + }; + + updateHeight(); + + const observer = new ResizeObserver(() => { + updateHeight(); + }); + observer.observe(card); + + return () => observer.disconnect(); + }, [changelogNotification.isVisible, changelogEntry]); + return (
@@ -228,183 +257,209 @@ export const LeftSidebar: React.FC = ({ )} - - - - - - handleReorderProjects(newOrder as Project[])} - className="m-0 flex min-w-0 list-none flex-col gap-1 p-0" - itemClassName="relative group cursor-pointer rounded-md list-none min-w-0" - getKey={(p) => (p as Project).id} - > - {(project) => { - const typedProject = project as Project; - const isProjectActive = selectedProject?.id === typedProject.id && !activeTask; - return ( - - { - if (forceOpenIds.has(typedProject.id)) { - setForceOpenIds((s) => { - const n = new Set(s); - n.delete(typedProject.id); - return n; - }); - } - }} - className="group/collapsible" - > -
- - - - - handleNavigationWithCloseSettings(() => - onSelectProject(typedProject) - ) + +
+ + + + + handleReorderProjects(newOrder as Project[])} + className="m-0 flex min-w-0 list-none flex-col gap-1 p-0" + itemClassName="relative group cursor-pointer rounded-md list-none min-w-0" + getKey={(p) => (p as Project).id} + > + {(project) => { + const typedProject = project as Project; + const isProjectActive = + selectedProject?.id === typedProject.id && !activeTask; + return ( + + { + if (forceOpenIds.has(typedProject.id)) { + setForceOpenIds((s) => { + const n = new Set(s); + n.delete(typedProject.id); + return n; + }); } + }} + className="group/collapsible" + > +
- - - {onCreateTaskForProject && ( - + + handleNavigationWithCloseSettings(() => - onCreateTaskForProject(typedProject) + onSelectProject(typedProject) ) } > - - - )} -
- - -
- {(tasksByProjectId[typedProject.id] ?? []) - .slice() - .sort( - (a, b) => - (b.metadata?.isPinned ? 1 : 0) - (a.metadata?.isPinned ? 1 : 0) - ) - .map((task) => { - const isActive = activeTask?.id === task.id; - return ( - - handleNavigationWithCloseSettings(() => - onSelectTask?.(task) - ) - } - className={`group/task min-w-0 rounded-md py-1.5 pl-1 pr-2 hover:bg-accent ${isActive ? 'bg-black/[0.06] dark:bg-white/[0.08]' : ''}`} - > - handlePinTask(task)} - onRename={(n) => onRenameTask?.(typedProject, task, n)} - onDelete={() => handleDeleteTask(typedProject, task)} - onArchive={() => onArchiveTask?.(typedProject, task)} - primaryAction={taskHoverAction} - /> - - ); - })} - {(archivedTasksByProjectId[typedProject.id]?.length ?? 0) > 0 && ( - - - - - -
- {archivedTasksByProjectId[typedProject.id].map( - (archivedTask) => ( -
- - {archivedTask.name} - -
- - - handleDeleteTask(typedProject, archivedTask) - } - /> -
-
- ) - )} -
-
-
+ + + {onCreateTaskForProject && ( + )}
-
-
-
- ); - }} -
-
-
-
- {projects.length === 0 && ( -
- +
+ {(tasksByProjectId[typedProject.id] ?? []) + .slice() + .sort( + (a, b) => + (b.metadata?.isPinned ? 1 : 0) - + (a.metadata?.isPinned ? 1 : 0) + ) + .map((task) => { + const isActive = activeTask?.id === task.id; + return ( + + handleNavigationWithCloseSettings(() => + onSelectTask?.(task) + ) + } + className={`group/task min-w-0 rounded-md py-1.5 pl-1 pr-2 hover:bg-accent ${isActive ? 'bg-black/[0.06] dark:bg-white/[0.08]' : ''}`} + > + handlePinTask(task)} + onRename={(n) => onRenameTask?.(typedProject, task, n)} + onDelete={() => handleDeleteTask(typedProject, task)} + onArchive={() => onArchiveTask?.(typedProject, task)} + primaryAction={taskHoverAction} + /> + + ); + })} + {(archivedTasksByProjectId[typedProject.id]?.length ?? 0) > 0 && ( + + + + + +
+ {archivedTasksByProjectId[typedProject.id].map( + (archivedTask) => ( +
+ + {archivedTask.name} + +
+ + + handleDeleteTask(typedProject, archivedTask) + } + /> +
+
+ ) + )} +
+
+
+ )} +
+ + + + ); + }} + + + + + {projects.length === 0 && ( +
+ +
+ )} +
+ + {changelogNotification.isVisible && changelogEntry && ( +
+ + showModal('changelogModal', { + entry: changelogEntry, + }) + } + onDismiss={changelogNotification.dismiss} />
)} diff --git a/src/renderer/contexts/ModalProvider.tsx b/src/renderer/contexts/ModalProvider.tsx index 2278feb25..915099a5c 100644 --- a/src/renderer/contexts/ModalProvider.tsx +++ b/src/renderer/contexts/ModalProvider.tsx @@ -6,9 +6,11 @@ import { TaskModalOverlay } from '@/components/TaskModal'; import { AddRemoteProjectModal } from '@/components/ssh/AddRemoteProjectModal'; import { GithubDeviceFlowModalOverlay } from '@/components/GithubDeviceFlowModal'; import { McpServerModal } from '@/components/mcp/McpServerModal'; +import { ChangelogModalOverlay } from '@/components/ChangelogModal'; // Define overlays here so we can use them in the showOverlay function const modalRegistry = { + changelogModal: ChangelogModalOverlay, updateModal: UpdateModalOverlay, newProjectModal: NewProjectModal, cloneFromUrlModal: CloneFromUrlModal, diff --git a/src/renderer/hooks/useChangelogNotification.ts b/src/renderer/hooks/useChangelogNotification.ts new file mode 100644 index 000000000..4418182ee --- /dev/null +++ b/src/renderer/hooks/useChangelogNotification.ts @@ -0,0 +1,104 @@ +import { useAppContext } from '@/contexts/AppContextProvider'; +import { useAppSettings } from '@/contexts/AppSettingsProvider'; +import { rpc } from '@/lib/rpc'; +import { + compareChangelogVersions, + normalizeChangelogVersion, + type ChangelogEntry, +} from '@shared/changelog'; +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +function createFallbackEntry(version: string): ChangelogEntry { + return { + version, + title: `Release highlights for Emdash v${version}`, + summary: `See what changed in Emdash v${version}.`, + content: `See what changed in Emdash v${version}.`, + }; +} + +function selectVersion( + installedVersion: string | null, + availableVersion: string | null, + dismissedVersions: string[] +): string | null { + const visibleInstalled = + installedVersion && !dismissedVersions.includes(installedVersion) ? installedVersion : null; + const visibleAvailable = + availableVersion && !dismissedVersions.includes(availableVersion) ? availableVersion : null; + + if (visibleInstalled && visibleAvailable) { + return compareChangelogVersions(visibleAvailable, visibleInstalled) >= 0 + ? visibleAvailable + : visibleInstalled; + } + + return visibleAvailable ?? visibleInstalled ?? null; +} + +export function useChangelogNotification() { + const { appVersion } = useAppContext(); + const { settings, updateSettings } = useAppSettings(); + const [availableVersion, setAvailableVersion] = useState(null); + + useEffect(() => { + let mounted = true; + + window.electronAPI + .getUpdateState?.() + .then((result) => { + if (!mounted || !result?.success) return; + setAvailableVersion(normalizeChangelogVersion(result.data?.availableVersion)); + }) + .catch(() => {}); + + const off = window.electronAPI?.onUpdateEvent?.((event) => { + if (event.type === 'available') { + setAvailableVersion(normalizeChangelogVersion(event.payload?.version)); + } + }); + + return () => { + mounted = false; + off?.(); + }; + }, []); + + const dismissedVersions = settings?.changelog?.dismissedVersions ?? []; + const installedVersion = normalizeChangelogVersion(appVersion); + const notificationVersion = useMemo( + () => selectVersion(installedVersion, availableVersion, dismissedVersions), + [installedVersion, availableVersion, dismissedVersions] + ); + + const { data } = useQuery({ + queryKey: ['changelog', notificationVersion], + enabled: Boolean(notificationVersion), + staleTime: 60 * 60 * 1000, + queryFn: async () => + rpc.changelog.getLatestEntry({ version: notificationVersion ?? undefined }), + }); + + const entry = useMemo( + () => (notificationVersion ? (data ?? createFallbackEntry(notificationVersion)) : null), + [data, notificationVersion] + ); + + const dismiss = useCallback(() => { + if (!notificationVersion) return; + + const nextDismissedVersions = [...new Set([...dismissedVersions, notificationVersion])]; + updateSettings({ + changelog: { + dismissedVersions: nextDismissedVersions, + }, + }); + }, [dismissedVersions, notificationVersion, updateSettings]); + + return { + entry, + isVisible: Boolean(entry), + dismiss, + }; +} diff --git a/src/shared/changelog.ts b/src/shared/changelog.ts new file mode 100644 index 000000000..625afaf13 --- /dev/null +++ b/src/shared/changelog.ts @@ -0,0 +1,97 @@ +export const EMDASH_CHANGELOG_URL = 'https://www.emdash.sh/changelog'; +export const EMDASH_CHANGELOG_API_URL = 'https://www.emdash.sh/api/changelog'; + +export interface ChangelogEntry { + version: string; + title: string; + summary: string; + content: string; + publishedAt?: string; + url?: string; +} + +export function normalizeChangelogVersion(version: string | null | undefined): string | null { + if (typeof version !== 'string') return null; + const trimmed = version.trim().replace(/^v/i, ''); + if (!trimmed) return null; + return /^[0-9]+(?:\.[0-9A-Za-z-]+){0,2}(?:[-+][0-9A-Za-z.-]+)?$/.test(trimmed) ? trimmed : null; +} + +type ParsedVersion = { + parts: number[]; + prerelease: string[]; +}; + +function parseVersion(version: string): ParsedVersion { + const [core, prerelease = ''] = version.split('-', 2); + const parts = core.split('.').map((part) => { + const value = Number.parseInt(part, 10); + return Number.isFinite(value) ? value : 0; + }); + + while (parts.length < 3) { + parts.push(0); + } + + return { + parts, + prerelease: prerelease + .split('.') + .map((part) => part.trim()) + .filter(Boolean), + }; +} + +function comparePrereleaseParts(left: string[], right: string[]): number { + if (left.length === 0 && right.length === 0) return 0; + if (left.length === 0) return 1; + if (right.length === 0) return -1; + + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const leftPart = left[index]; + const rightPart = right[index]; + + if (leftPart === undefined) return -1; + if (rightPart === undefined) return 1; + + const leftIsNumber = /^\d+$/.test(leftPart); + const rightIsNumber = /^\d+$/.test(rightPart); + + if (leftIsNumber && rightIsNumber) { + const delta = Number.parseInt(leftPart, 10) - Number.parseInt(rightPart, 10); + if (delta !== 0) return delta > 0 ? 1 : -1; + continue; + } + + if (leftIsNumber) return -1; + if (rightIsNumber) return 1; + + const comparison = leftPart.localeCompare(rightPart); + if (comparison !== 0) return comparison > 0 ? 1 : -1; + } + + return 0; +} + +export function compareChangelogVersions( + left: string | null | undefined, + right: string | null | undefined +): number { + const normalizedLeft = normalizeChangelogVersion(left); + const normalizedRight = normalizeChangelogVersion(right); + + if (!normalizedLeft && !normalizedRight) return 0; + if (!normalizedLeft) return -1; + if (!normalizedRight) return 1; + + const parsedLeft = parseVersion(normalizedLeft); + const parsedRight = parseVersion(normalizedRight); + + for (let index = 0; index < 3; index += 1) { + const delta = parsedLeft.parts[index] - parsedRight.parts[index]; + if (delta !== 0) return delta > 0 ? 1 : -1; + } + + return comparePrereleaseParts(parsedLeft.prerelease, parsedRight.prerelease); +} diff --git a/src/test/main/ChangelogService.test.ts b/src/test/main/ChangelogService.test.ts new file mode 100644 index 000000000..a091c78c8 --- /dev/null +++ b/src/test/main/ChangelogService.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { compareChangelogVersions, normalizeChangelogVersion } from '../../shared/changelog'; +import { parseChangelogHtml } from '../../main/services/ChangelogService'; + +describe('normalizeChangelogVersion', () => { + it('strips a leading v prefix', () => { + expect(normalizeChangelogVersion('v1.2.3')).toBe('1.2.3'); + }); + + it('returns null for invalid values', () => { + expect(normalizeChangelogVersion('latest')).toBeNull(); + }); +}); + +describe('compareChangelogVersions', () => { + it('sorts stable versions numerically', () => { + expect(compareChangelogVersions('1.10.0', '1.2.0')).toBeGreaterThan(0); + }); + + it('treats stable releases as newer than prereleases', () => { + expect(compareChangelogVersions('1.2.0', '1.2.0-beta.1')).toBeGreaterThan(0); + }); +}); + +describe('parseChangelogHtml', () => { + const html = ` +
+
+ +

Task polish release

+

Improves task creation.

+

Quick create

+

Create tasks faster from the sidebar.

+
+
+ +

Changelog notifications

+

See release notes directly in the app.

+

Sidebar card

+

A compact notification lives at the bottom of the sidebar.

+
    +
  • Dismiss per version
  • +
  • Open the full modal
  • +
+
+
+ `; + + it('picks the exact requested version when present', () => { + const entry = parseChangelogHtml(html, '0.4.30'); + + expect(entry?.version).toBe('0.4.30'); + expect(entry?.title).toBe('Task polish release'); + expect(entry?.summary).toContain('Improves task creation'); + }); + + it('falls back to the newest entry when no version is requested', () => { + const entry = parseChangelogHtml(html); + + expect(entry?.version).toBe('0.4.31'); + expect(entry?.title).toBe('Changelog notifications'); + expect(entry?.content).toContain('## Sidebar card'); + expect(entry?.content).toContain('- Dismiss per version'); + }); +}); diff --git a/src/test/main/settings.test.ts b/src/test/main/settings.test.ts index 750219ce3..75833721e 100644 --- a/src/test/main/settings.test.ts +++ b/src/test/main/settings.test.ts @@ -78,3 +78,22 @@ describe('normalizeSettings – autoInferTaskNames', () => { expect(result.tasks?.autoInferTaskNames).toBe(false); }); }); + +describe('normalizeSettings - changelog dismissed versions', () => { + it('normalizes, trims, and deduplicates versions', () => { + const result = normalizeSettings( + makeSettings({ + changelog: { + dismissedVersions: [' v0.4.31 ', '0.4.31', 'v0.4.30'], + }, + }) + ); + + expect(result.changelog?.dismissedVersions).toEqual(['0.4.31', '0.4.30']); + }); + + it('defaults to an empty list when missing', () => { + const result = normalizeSettings(makeSettings()); + expect(result.changelog?.dismissedVersions).toEqual([]); + }); +});