Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ jobs:
- name: Generate updater manifest
env:
TAG: ${{ needs.release-please.outputs.tag_name }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
working-directory: src-tauri/target/release/bundle/macos
run: |
VERSION="${TAG#v}"
Expand All @@ -129,15 +128,18 @@ jobs:
PUB_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
DOWNLOAD_URL="https://github.com/quiet-node/thuki/releases/download/${TAG}/${PAYLOAD}"

# Embed the full release-please changelog (the GitHub Release body
# for this tag) so the in-app updater shows the real notes instead
# of a bare link. release-please always creates this release with a
# non-empty body before this job runs, so an empty body means
# something upstream broke: fail loudly rather than ship a manifest
# whose notes are blank.
gh release view "$TAG" --json body --jq .body > release-notes.md
# Embed the FULL released changelog (every version section, not just
# this tag's) so the in-app "What's New" window can show every release
# a user skipped over. The window parses these headers and slices to
# the user's installed version client-side. We take everything from
# the first `## [x.y.z]` header to EOF, which drops the document title,
# the preamble, and the "## Unreleased" block (all of which sit above
# the first version header). An empty result means CHANGELOG.md has no
# released sections yet: fail loudly rather than ship blank notes.
awk '/^## \[[0-9]+\.[0-9]+\.[0-9]+\]/ {found=1} found {print}' \
"$GITHUB_WORKSPACE/CHANGELOG.md" > release-notes.md
if [ ! -s release-notes.md ]; then
echo "::error::GitHub Release body for ${TAG} is empty; refusing to publish a manifest with no notes." >&2
echo "::error::CHANGELOG.md has no released version sections; refusing to publish a manifest with no notes." >&2
exit 1
fi

Expand Down
29 changes: 27 additions & 2 deletions src/components/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { memo } from 'react';
import React, { memo, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Streamdown, type MathPlugin } from 'streamdown';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
Expand Down Expand Up @@ -37,6 +38,13 @@ const mathPlugin: MathPlugin = {
* relies on KaTeX's own output escaping and the default trust:false setting,
* which blocks arbitrary-HTML LaTeX macros such as \href and \htmlClass.
*
* External links: a bare `<a target="_blank">` does nothing in a Tauri
* WKWebView (the webview will not navigate to the system browser on its
* own), so anchor clicks are intercepted here and routed through the
* `open_url` command, which opens the URL in the user's default browser.
* `open_url` only accepts http/https, so non-web schemes are rejected
* there. This is the same mechanism `TipBar` uses for its links.
*
* Currency disambiguation: `remark-math` would otherwise parse the text
* between two currency dollars (e.g. "raise $1M ... reach $1M") as one
* giant inline-math run. `escapeCurrencyDollars` escapes `$<digit>` before
Expand All @@ -51,8 +59,25 @@ const mathPlugin: MathPlugin = {
*/
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
function MarkdownRenderer({ content, className = '', isStreaming = false }) {
/**
* Delegated anchor-click handler. Streamdown renders links as native
* `<a target="_blank">` elements, which a Tauri WKWebView silently
* ignores. Intercept the click, hand the href to `open_url`, and let
* the backend open it in the default browser. Non-anchor clicks (text,
* code copy button, etc.) fall through untouched.
*/
const handleClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
const anchor = (e.target as HTMLElement).closest('a');
const href = anchor?.getAttribute('href');
if (!href) return;
e.preventDefault();
void invoke('open_url', { url: href }).catch((err: unknown) => {
console.error('failed to open link', href, err);
});
}, []);

return (
<span className={`markdown-body ${className}`}>
<span className={`markdown-body ${className}`} onClick={handleClick}>
<Streamdown
mode={isStreaming ? 'streaming' : 'static'}
/* Force dark syntax highlighting - the app has no .dark root class
Expand Down
74 changes: 72 additions & 2 deletions src/components/__tests__/MarkdownRenderer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { act, render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import {
act,
createEvent,
fireEvent,
render,
screen,
} from '@testing-library/react';
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import { invoke } from '@tauri-apps/api/core';
import { MarkdownRenderer } from '../MarkdownRenderer';

const invokeMock = invoke as unknown as ReturnType<typeof vi.fn>;

describe('MarkdownRenderer', () => {
describe('Basic rendering', () => {
it('renders plain text as paragraph', () => {
Expand Down Expand Up @@ -287,6 +296,67 @@ describe('MarkdownRenderer', () => {
});
});

describe('External link handling', () => {
beforeEach(() => {
invokeMock.mockReset();
invokeMock.mockResolvedValue(undefined);
});

afterEach(() => {
vi.restoreAllMocks();
});

it('opens a clicked link in the system browser via open_url', async () => {
await act(async () => {
render(
<MarkdownRenderer content="[Visit site](https://example.com)" />,
);
await new Promise((resolve) => setTimeout(resolve, 0));
});

const link = await screen.findByRole('link', { name: 'Visit site' });
const clickEvent = createEvent.click(link);
fireEvent(link, clickEvent);

expect(clickEvent.defaultPrevented).toBe(true);
expect(invokeMock).toHaveBeenCalledWith('open_url', {
url: 'https://example.com/',
});
});

it('ignores clicks on non-anchor content', () => {
const { container } = render(<MarkdownRenderer content="just text" />);
fireEvent.click(container.querySelector('span.markdown-body')!);
expect(invokeMock).not.toHaveBeenCalled();
});

it('logs when open_url rejects so failures are never swallowed', async () => {
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
invokeMock.mockRejectedValue(new Error('nope'));

await act(async () => {
render(
<MarkdownRenderer content="[Visit site](https://example.com)" />,
);
await new Promise((resolve) => setTimeout(resolve, 0));
});

const link = await screen.findByRole('link', { name: 'Visit site' });
await act(async () => {
fireEvent.click(link);
await Promise.resolve();
});

expect(consoleError).toHaveBeenCalledWith(
'failed to open link',
'https://example.com/',
expect.any(Error),
);
});
});

describe('Edge cases', () => {
it('handles empty string', () => {
const { container } = render(<MarkdownRenderer content="" />);
Expand Down
81 changes: 81 additions & 0 deletions src/view/update/ChangelogAccordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useCallback, useState } from 'react';

import { MarkdownRenderer } from '../../components/MarkdownRenderer';
import type { ChangelogSection } from './changelog';

interface ChangelogAccordionProps {
sections: ChangelogSection[];
}

/**
* Collapsible per-version release notes for the "What's New" window. The newest
* version (first, since `selectSections` sorts newest-first) starts expanded;
* older versions collapse to a clickable header row so a multi-version jump does
* not become a wall of text. Each body reuses `MarkdownRenderer`, so links open
* in the system browser like everywhere else.
*/
export function ChangelogAccordion({ sections }: ChangelogAccordionProps) {
const [expanded, setExpanded] = useState<Set<string>>(
() => new Set(sections.slice(0, 1).map((s) => s.version)),
);

const toggle = useCallback((version: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(version)) next.delete(version);
else next.add(version);
return next;
});
}, []);

return (
<div data-testid="changelog-accordion">
{sections.map((section) => {
const isOpen = expanded.has(section.version);
return (
<div
key={section.version}
className="border-b border-white/[0.045] last:border-b-0"
>
<button
type="button"
onClick={() => toggle(section.version)}
aria-expanded={isOpen}
className="flex w-full items-center gap-2 py-[10px] text-left cursor-pointer"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className={`h-3 w-3 shrink-0 text-text-secondary transition-transform duration-150 ${
isOpen ? 'rotate-90' : ''
}`}
>
<path
d="M6 4l4 4-4 4"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className="text-[13px] font-bold text-primary">
{section.version}
</span>
{section.date ? (
<span className="text-[12px] text-text-secondary">
{section.date}
</span>
) : null}
</button>
{isOpen ? (
<div className="pb-3 pl-5">
<MarkdownRenderer content={section.body} />
</div>
) : null}
</div>
);
})}
</div>
);
}
50 changes: 36 additions & 14 deletions src/view/update/UpdateWindow.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
/**
* Top-level component for the "What's New" update NSWindow.
*
* Mounted by `rootForLabel` when the Tauri window label is `update`. Shows
* the available version's release notes (rendered markdown from the updater
* manifest, with a GitHub-link fallback when the manifest omits notes) and
* three explicit actions so an install never starts on a single stray click:
* Mounted by `rootForLabel` when the Tauri window label is `update`. The
* manifest body carries the full release history, so the window shows every
* version between the user's installed build and the latest as a collapsible
* accordion (newest expanded). When the body has no parseable version headers
* it falls back to rendering the markdown as-is, with a GitHub-link fallback
* when the manifest omits notes. Three explicit actions keep an install from
* ever starting on a single stray click:
*
* - Skip This Version : never nag for this exact version again
* - Remind Me Later : snooze both surfaces for 24h
Expand All @@ -28,13 +31,15 @@
* cheap and React state is preserved.
*/

import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { getVersion } from '@tauri-apps/api/app';

import { useUpdater } from '../../hooks/useUpdater';
import { MarkdownRenderer } from '../../components/MarkdownRenderer';
import { WindowControls } from '../../components/WindowControls';
import { ChangelogAccordion } from './ChangelogAccordion';
import { parseChangelogSections, selectSections } from './changelog';

export function UpdateWindow() {
const updater = useUpdater();
Expand All @@ -50,6 +55,19 @@ export function UpdateWindow() {
.catch(() => {});
}, []);

// The manifest body carries the full release history; show only the versions
// the user skipped over (newest first). Empty when the body has no version
// headers (old single-version manifests, or the GitHub-link fallback text),
// in which case the window renders the body as-is below.
const sections = useMemo(
() =>
selectSections(
parseChangelogSections(update?.body ?? ''),
currentVersion,
),
[update?.body, currentVersion],
);

const close = useCallback(() => {
void getCurrentWindow().hide();
}, []);
Expand Down Expand Up @@ -162,15 +180,19 @@ export function UpdateWindow() {
className="update-notes mx-12 min-h-0 flex-1 overflow-y-auto p-4 text-[13px] leading-[1.55]"
data-testid="update-notes"
>
<MarkdownRenderer
content={
update.body && update.body.trim().length > 0
? update.body
: update.notes_url
? `Release notes for this version aren't bundled in the update manifest. [View them on GitHub](${update.notes_url}).`
: 'No release notes are available for this version.'
}
/>
{sections.length > 0 ? (
<ChangelogAccordion sections={sections} />
) : (
<MarkdownRenderer
content={
update.body && update.body.trim().length > 0
? update.body
: update.notes_url
? `Release notes for this version aren't bundled in the update manifest. [View them on GitHub](${update.notes_url}).`
: 'No release notes are available for this version.'
}
/>
)}
</div>

<footer className="flex flex-nowrap items-center gap-2 border-t border-[rgba(255,255,255,0.045)] px-9 pt-[16px] pb-5">
Expand Down
58 changes: 58 additions & 0 deletions src/view/update/__tests__/ChangelogAccordion.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';

import { ChangelogAccordion } from '../ChangelogAccordion';
import type { ChangelogSection } from '../changelog';

const SECTIONS: ChangelogSection[] = [
{
version: '0.14.0',
date: '2026-06-07',
body: '### Features\n\n* newest thing',
},
{ version: '0.13.0', date: null, body: '### Bug Fixes\n\n* older fix' },
];

describe('ChangelogAccordion', () => {
it('renders a row per version with the version label', () => {
render(<ChangelogAccordion sections={SECTIONS} />);
expect(screen.getByText('0.14.0')).toBeInTheDocument();
expect(screen.getByText('0.13.0')).toBeInTheDocument();
});

it('shows the date when present and omits it when null', () => {
render(<ChangelogAccordion sections={SECTIONS} />);
expect(screen.getByText('2026-06-07')).toBeInTheDocument();
});

it('expands the newest version by default and collapses the rest', () => {
render(<ChangelogAccordion sections={SECTIONS} />);
expect(screen.getByText('newest thing')).toBeInTheDocument();
expect(screen.queryByText('older fix')).not.toBeInTheDocument();
});

it('marks the open row aria-expanded and the closed row not', () => {
render(<ChangelogAccordion sections={SECTIONS} />);
expect(screen.getByRole('button', { name: /0\.14\.0/ })).toHaveAttribute(
'aria-expanded',
'true',
);
expect(screen.getByRole('button', { name: /0\.13\.0/ })).toHaveAttribute(
'aria-expanded',
'false',
);
});

it('expands a collapsed version when its header is clicked', () => {
render(<ChangelogAccordion sections={SECTIONS} />);
fireEvent.click(screen.getByRole('button', { name: /0\.13\.0/ }));
expect(screen.getByText('older fix')).toBeInTheDocument();
});

it('collapses the newest version when its header is clicked', () => {
render(<ChangelogAccordion sections={SECTIONS} />);
expect(screen.getByText('newest thing')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /0\.14\.0/ }));
expect(screen.queryByText('newest thing')).not.toBeInTheDocument();
});
});
Loading