diff --git a/.changeset/subscribe-card.md b/.changeset/subscribe-card.md new file mode 100644 index 000000000..6255965ae --- /dev/null +++ b/.changeset/subscribe-card.md @@ -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. diff --git a/knip.config.ts b/knip.config.ts index 662ca81cf..2dd0347d0 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -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, diff --git a/packages/app/src/components/HelpPopover.tsx b/packages/app/src/components/HelpPopover.tsx index e2d703f18..1379c6c2b 100644 --- a/packages/app/src/components/HelpPopover.tsx +++ b/packages/app/src/components/HelpPopover.tsx @@ -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; @@ -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 }, ], }, @@ -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. */} - setSubscribeOpen(false)} /> + setSubscribeOpen(false)} + onSuccess={() => subscribeCardStore.markSubscribed()} + /> diff --git a/packages/app/src/components/SubscribeCard.dom.test.tsx b/packages/app/src/components/SubscribeCard.dom.test.tsx new file mode 100644 index 000000000..b0e2d8eb9 --- /dev/null +++ b/packages/app/src/components/SubscribeCard.dom.test.tsx @@ -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>, +); +mock.module('@/lib/subscribe', () => ({ submitSubscribe })); + +function memoryStorage(): SubscribeCardStorage { + const map = new Map(); + 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( + , + ); + 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)); + }); +}); diff --git a/packages/app/src/components/SubscribeCard.tsx b/packages/app/src/components/SubscribeCard.tsx new file mode 100644 index 000000000..64c0f5940 --- /dev/null +++ b/packages/app/src/components/SubscribeCard.tsx @@ -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>; +}> = ({ href, label, icon: Icon }) => ( + 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" + > + +); + +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 ( +
+
+ Product updates in your inbox.} + onSuccess={() => { + store.markSubscribed(); + setSucceeded(true); + }} + onDismiss={() => { + store.dismiss(); + onClose(); + }} + /> + {succeeded ? null : ( + + )} +
+
+ + Updated to Version {version} + + +
+
+ ); +} diff --git a/packages/app/src/components/SubscribeForm.tsx b/packages/app/src/components/SubscribeForm.tsx index 0104d3b60..87db31a71 100644 --- a/packages/app/src/components/SubscribeForm.tsx +++ b/packages/app/src/components/SubscribeForm.tsx @@ -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'; @@ -20,6 +20,7 @@ export interface SubscribeFormProps { onSuccess?: () => void; onDismiss?: () => void; autoFocus?: boolean; + compactSubmit?: boolean; className?: string; } @@ -29,6 +30,7 @@ export function SubscribeForm({ onSuccess, onDismiss, autoFocus, + compactSubmit, className, }: SubscribeFormProps) { const { t } = useLingui(); @@ -79,7 +81,7 @@ export function SubscribeForm({

{subscribed ? ( @@ -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). */} -

+

{subscribed ? ( Thanks for subscribing. Watch your inbox for product updates. ) : ( @@ -143,7 +145,11 @@ export function SubscribeForm({