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
5 changes: 5 additions & 0 deletions .changeset/subscribe-card.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge": minor
---

Invite OK Desktop users to subscribe to product updates from the post-update release-notes card. When the app updates, the "Updated to Version X · Release notes" card in the sidebar footer now also carries a compact "Stay in the loop" subscribe form (reusing the existing form) with a "Follow us on" row for X, GitHub, and Discord — shown only when the user hasn't already subscribed or dismissed it, and for at most three distinct update versions. It never appears on its own (no update, no prompt) and won't re-nag on reopen. Dismissing closes the whole card and stops the prompt for good; subscribing from here or the Resources menu retires it. All state is device-local; web and CLI are unaffected.
3 changes: 1 addition & 2 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,7 @@ export default {
project: 'src/**',
ignoreDependencies: [
'@tiptap/extension-collaboration-cursor', // transitive dependency for `y-prosemirror@1.3.7` patch
'@hookform/resolvers', // intentionally installed but uninstantiated (resolver-less); kept for parity with agents-private and future schema-bound dialogs
'fuzzysort', // installed by PR #361 (workspace omnibar search) ahead of the consumer wire-up; same idiom as @hookform/resolvers
'fuzzysort', // installed by PR #361 (workspace omnibar search) ahead of the consumer wire-up
'@testing-library/jest-dom', // side-effect import (`import '@testing-library/jest-dom'`) registers matchers
'highlight.js', // lowlight's peer dependency — never imported here directly, but lowlight's grammar registrations resolve through it
...fidelityOnlyAppDeps,
Expand Down
14 changes: 9 additions & 5 deletions packages/app/src/components/HelpPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { dispatchExternalLinkClick } from '@/lib/external-link';
import { DISCORD_INVITE_URL, GITHUB_REPO_URL, X_PROFILE_URL } from '@/lib/social-links';
import { subscribeCardStore } from '@/lib/subscribe-card-store';
import { cn } from '@/lib/utils';
import { DiscordIcon } from './icons/discord';
import { GithubIcon } from './icons/github';
import { XTwitterIcon } from './icons/x-twitter';

const GITHUB_REPO_URL = 'https://github.com/inkeep/open-knowledge';

interface ResourceLink {
label: string | MessageDescriptor;
href: string;
Expand All @@ -43,8 +43,8 @@ const sections: ResourceSection[] = [
key: 'community',
heading: msg`Community`,
links: [
{ label: 'Discord', href: 'https://discord.com/invite/YujKpFN49', icon: DiscordIcon },
{ label: 'X (Twitter)', href: 'https://x.com/OpenKnowledgeAI', icon: XTwitterIcon },
{ label: 'Discord', href: DISCORD_INVITE_URL, icon: DiscordIcon },
{ label: 'X (Twitter)', href: X_PROFILE_URL, icon: XTwitterIcon },
{ label: 'GitHub', href: GITHUB_REPO_URL, icon: GithubIcon },
],
},
Expand Down Expand Up @@ -189,7 +189,11 @@ export const HelpPopover: FC = () => {
by the dropdown's p-3, so sideOffset clears that padding
plus a gap rather than overlapping the menu. */}
<PopoverContent side="left" align="center" sideOffset={20} className="w-80">
<SubscribeForm autoFocus onDismiss={() => setSubscribeOpen(false)} />
<SubscribeForm
autoFocus
onDismiss={() => setSubscribeOpen(false)}
onSuccess={() => subscribeCardStore.markSubscribed()}
/>
</PopoverContent>
</Popover>
</li>
Expand Down
118 changes: 118 additions & 0 deletions packages/app/src/components/SubscribeCard.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { afterEach, describe, expect, mock, test } from 'bun:test';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import {
createSubscribeCardStore,
type SubscribeCardStorage,
type SubscribeCardStore,
} from '@/lib/subscribe-card-store';
import { renderLinguiTemplate } from '@/test-utils/lingui-mock';

mock.module('@lingui/react/macro', () => ({
Trans: ({ children }: { children: ReactNode }) => <>{children}</>,
useLingui: () => ({ t: renderLinguiTemplate }),
}));

const submitSubscribe = mock(
async (_email: string) =>
({ ok: true }) as Awaited<ReturnType<typeof import('@/lib/subscribe').submitSubscribe>>,
);
mock.module('@/lib/subscribe', () => ({ submitSubscribe }));

function memoryStorage(): SubscribeCardStorage {
const map = new Map<string, string>();
return {
getItem: (k) => map.get(k) ?? null,
setItem: (k, v) => {
map.set(k, v);
},
};
}

function makeStore(): SubscribeCardStore {
return createSubscribeCardStore(memoryStorage());
}

async function renderCard(
overrides: Partial<{
store: SubscribeCardStore;
onOpenReleaseNotes: () => void;
onClose: () => void;
autoDismissMs: number;
}> = {},
) {
const { SubscribeCard } = await import('./SubscribeCard');
const props = {
version: '1.4.0',
onOpenReleaseNotes: mock(() => {}),
onClose: mock(() => {}),
store: makeStore(),
autoDismissMs: 5,
...overrides,
};
render(
<SubscribeCard
version={props.version}
onOpenReleaseNotes={props.onOpenReleaseNotes}
onClose={props.onClose}
store={props.store}
autoDismissMs={props.autoDismissMs}
/>,
);
return props;
}

afterEach(() => {
cleanup();
submitSubscribe.mockReset();
submitSubscribe.mockResolvedValue({ ok: true });
});

describe('SubscribeCard (combined release-notes + subscribe)', () => {
test('renders the form, social links, and the release-notes footer', async () => {
await renderCard();

expect(screen.getByTestId('subscribe-email')).toBeTruthy();
expect(screen.getByText('Follow us on')).toBeTruthy();
expect(screen.getByText(/Updated to Version/)).toBeTruthy();
expect(screen.getByText('1.4.0', { exact: false })).toBeTruthy();

const hrefs = Array.from(document.querySelectorAll('a')).map((a) => a.getAttribute('href'));
expect(hrefs).toContain('https://x.com/OpenKnowledgeAI');
expect(hrefs).toContain('https://github.com/inkeep/open-knowledge');
expect(hrefs).toContain('https://discord.com/invite/YujKpFN49');
});

test('clicking Release notes opens the release notes', async () => {
const onOpenReleaseNotes = mock(() => {});
await renderCard({ onOpenReleaseNotes });
await userEvent.click(screen.getByRole('button', { name: 'Release notes' }));
expect(onOpenReleaseNotes).toHaveBeenCalledTimes(1);
});

test('dismissing closes the card and stops the prompt for good', async () => {
const onClose = mock(() => {});
const store = makeStore();
await renderCard({ store, onClose });
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(store.getSnapshot().dismissed).toBe(true);
expect(onClose).toHaveBeenCalledTimes(1);
});

test('a confirmed subscribe marks subscribed, collapses socials, then auto-dismisses', async () => {
submitSubscribe.mockResolvedValue({ ok: true });
const onClose = mock(() => {});
const store = makeStore();
await renderCard({ store, onClose, autoDismissMs: 5 });

await userEvent.type(screen.getByTestId('subscribe-email'), 'someone@example.com');
await userEvent.click(screen.getByTestId('subscribe-submit'));

await waitFor(() => expect(submitSubscribe).toHaveBeenCalled());
expect(store.getSnapshot().subscribed).toBe(true);
expect(screen.queryByText('Follow us on')).toBeNull();
expect(screen.getByText(/Updated to Version/)).toBeTruthy();
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
});
});
106 changes: 106 additions & 0 deletions packages/app/src/components/SubscribeCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { Trans, useLingui } from '@lingui/react/macro';
import type { ComponentProps, FC } from 'react';
import { useEffect, useState } from 'react';
import { SubscribeForm } from '@/components/SubscribeForm';
import { Button } from '@/components/ui/button';
import { dispatchExternalLinkClick } from '@/lib/external-link';
import { DISCORD_INVITE_URL, GITHUB_REPO_URL, X_PROFILE_URL } from '@/lib/social-links';
import { type SubscribeCardStore, subscribeCardStore } from '@/lib/subscribe-card-store';
import { DiscordIcon } from './icons/discord';
import { GithubIcon } from './icons/github';
import { XTwitterIcon } from './icons/x-twitter';

const SUCCESS_AUTO_DISMISS_MS = 60_000;

const SocialLink: FC<{
href: string;
label: string;
icon: FC<ComponentProps<'svg'>>;
}> = ({ href, label, icon: Icon }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => dispatchExternalLinkClick(e, href)}
onAuxClick={(e) => dispatchExternalLinkClick(e, href)}
aria-label={label}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Icon aria-hidden="true" className="size-3.5" />
</a>
);

export function SubscribeCard({
version,
onOpenReleaseNotes,
onClose,
store = subscribeCardStore,
autoDismissMs = SUCCESS_AUTO_DISMISS_MS,
}: {
version: string;
onOpenReleaseNotes: () => void;
onClose: () => void;
store?: SubscribeCardStore;
autoDismissMs?: number;
}) {
const { t } = useLingui();
const [succeeded, setSucceeded] = useState(false);

useEffect(() => {
if (!succeeded) return;
const timer = setTimeout(onClose, autoDismissMs);
return () => clearTimeout(timer);
}, [succeeded, onClose, autoDismissMs]);

return (
<section
aria-label={t`Stay in the loop`}
className="mx-1 mb-1 overflow-hidden rounded-lg border bg-card text-card-foreground"
>
<div className="px-3 py-2.5">
<SubscribeForm
compactSubmit
description={<Trans>Product updates in your inbox.</Trans>}
onSuccess={() => {
store.markSubscribed();
setSucceeded(true);
}}
onDismiss={() => {
store.dismiss();
onClose();
}}
/>
{succeeded ? null : (
<nav
aria-label={t`Follow us on social media`}
className="mt-3 flex items-center gap-1.5 text-muted-foreground text-xs"
>
<span className="mr-0.5">
<Trans>Follow us on</Trans>
</span>
<SocialLink href={X_PROFILE_URL} label={t`Follow us on X`} icon={XTwitterIcon} />
<SocialLink href={GITHUB_REPO_URL} label={t`Star us on GitHub`} icon={GithubIcon} />
<SocialLink
href={DISCORD_INVITE_URL}
label={t`Join us on Discord`}
icon={DiscordIcon}
/>
</nav>
)}
</div>
<div className="flex items-center justify-between border-t bg-muted/30 px-3 py-2.5 space-x-2">
<span className="text-xs text-muted-foreground">
<Trans>Updated to Version {version}</Trans>
</span>
<Button
variant="link"
size="sm"
className="h-auto p-0 text-muted-foreground text-xs hover:text-foreground"
onClick={onOpenReleaseNotes}
>
<Trans>Release notes</Trans>
</Button>
</div>
</section>
);
}
21 changes: 17 additions & 4 deletions packages/app/src/components/SubscribeForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { Check, Loader2, Mail, X } from 'lucide-react';
import { ArrowRight, Check, Loader2, Mail, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
Expand All @@ -20,6 +20,7 @@ export interface SubscribeFormProps {
onSuccess?: () => void;
onDismiss?: () => void;
autoFocus?: boolean;
compactSubmit?: boolean;
className?: string;
}

Expand All @@ -29,6 +30,7 @@ export function SubscribeForm({
onSuccess,
onDismiss,
autoFocus,
compactSubmit,
className,
}: SubscribeFormProps) {
const { t } = useLingui();
Expand Down Expand Up @@ -79,7 +81,7 @@ export function SubscribeForm({
<p
ref={successHeadingRef}
tabIndex={subscribed ? -1 : undefined}
className="text-base font-medium leading-tight text-foreground focus:outline-none"
className="text-sm font-medium leading-tight text-foreground focus:outline-none"
>
{subscribed ? (
<span className="mb-1.5 inline-flex flex-row items-center gap-2">
Expand All @@ -94,7 +96,7 @@ export function SubscribeForm({
before the success text replaces the description — adding the role
and the new content in the same render makes screen readers miss
the announcement (WCAG 4.1.3). */}
<p className="text-sm text-muted-foreground" role="status">
<p className="text-1sm text-muted-foreground" role="status">
{subscribed ? (
<Trans>Thanks for subscribing. Watch your inbox for product updates.</Trans>
) : (
Expand Down Expand Up @@ -143,7 +145,11 @@ export function SubscribeForm({
<Button
type="submit"
disabled={isSubmitting}
className="mr-1 ml-auto text-1sm"
size={compactSubmit ? 'icon' : 'default'}
className={cn(
'mr-1 ml-auto text-1sm',
compactSubmit && 'size-8 shrink-0 rounded-lg',
)}
data-testid="subscribe-submit"
>
{isSubmitting ? (
Expand All @@ -153,6 +159,13 @@ export function SubscribeForm({
<Trans>Subscribing...</Trans>
</span>
</>
) : compactSubmit ? (
<>
<ArrowRight className="size-4" aria-hidden />
<span className="sr-only">
<Trans>Subscribe</Trans>
</span>
</>
) : (
<Trans>Subscribe</Trans>
)}
Expand Down
26 changes: 26 additions & 0 deletions packages/app/src/components/UpdateNotices.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export interface UpdateNotice {
onDismiss?: () => void;
variant?: 'info' | 'error' | 'success';
priority: number;
whatsNew?: { version: string; releaseUrl: string };
combinedSubscribe?: boolean;
dismissible?: boolean;
}

Expand All @@ -63,6 +65,10 @@ export function attachUpdateSubscribers(
addNotice: AddNoticeFn,
dismissNotice: DismissNoticeFn = () => {},
autoDismissMs: number = WHATS_NEW_AUTO_DISMISS_MS,
subscribeCombined: {
isEligible: (version: string) => boolean;
onShown: (version: string) => void;
} = { isEligible: () => false, onShown: () => {} },
): () => void {
const unsubscribers: Array<() => void> = [];
const autoDismissTimers = new Set<ReturnType<typeof setTimeout>>();
Expand Down Expand Up @@ -167,6 +173,26 @@ export function attachUpdateSubscribers(

unsubscribers.push(
bridge.onWhatsNew(({ version, releaseUrl }) => {
if (subscribeCombined.isEligible(version)) {
subscribeCombined.onShown(version);
addNotice({
id: `whats-new-combined-${version}`,
body: toastBBody(version),
variant: 'success',
priority: PRIORITY_WHATS_NEW,
combinedSubscribe: true,
whatsNew: { version, releaseUrl },
action: {
label: TOAST_B_ACTION,
onClick: () => {
void bridge.shell.openExternal(releaseUrl);
},
},
});
void bridge.update.dismissWhatsNew(version);
return;
}

const noticeId = `whats-new-${version}`;
addNotice({
id: noticeId,
Expand Down
Loading