diff --git a/app/src/App.tsx b/app/src/App.tsx index 990eea384..1572a0e17 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -11,6 +11,7 @@ import { PersistGate } from 'redux-persist/integration/react'; import AppRoutes from './AppRoutes'; import WebviewHost from './components/accounts/WebviewHost'; +import AnnouncementGate from './components/Announcement/AnnouncementGate'; import AppBackground from './components/AppBackground'; import AppUpdatePrompt from './components/AppUpdatePrompt'; import BootCheckGate from './components/BootCheckGate/BootCheckGate'; @@ -156,6 +157,7 @@ function App() { {!onMobile && } + diff --git a/app/src/components/Announcement/AnnouncementGate.test.tsx b/app/src/components/Announcement/AnnouncementGate.test.tsx new file mode 100644 index 000000000..379ee2efa --- /dev/null +++ b/app/src/components/Announcement/AnnouncementGate.test.tsx @@ -0,0 +1,109 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Announcement } from '../../services/announcementService'; +import { markAnnouncementShown } from '../../store/announcementSlice'; +import AnnouncementGate from './AnnouncementGate'; + +// Controllable mock state shared across the mocked modules. +const authState: { isAuthenticated: boolean; userId: string | null } = { + isAuthenticated: true, + userId: 'u1', +}; +let shownIds: string[] = []; +const dispatch = vi.fn(); +const fetchLatestAnnouncement = vi.fn(); + +vi.mock('../../providers/CoreStateProvider', () => ({ + useCoreState: () => ({ snapshot: { auth: authState } }), +})); + +vi.mock('../../services/announcementService', () => ({ + fetchLatestAnnouncement: () => fetchLatestAnnouncement(), +})); + +vi.mock('../../store/hooks', () => ({ + useAppDispatch: () => dispatch, + // Run the real selector against our controllable state. + useAppSelector: (sel: (s: unknown) => unknown) => sel({ announcement: { shownIds } }), +})); + +// Stub the modal so this suite tests gate logic, not presentation. +vi.mock('./AnnouncementModal', () => ({ + default: ({ announcement, onDismiss }: { announcement: Announcement; onDismiss: () => void }) => ( +
+ {announcement.title} + +
+ ), +})); + +const sample: Announcement = { + id: 'a1', + title: 'Hello', + body: 'World', + severity: 'INFO', + cta: null, + startsAt: null, + expiresAt: null, + createdAt: null, +}; + +describe('AnnouncementGate', () => { + beforeEach(() => { + authState.isAuthenticated = true; + authState.userId = 'u1'; + shownIds = []; + dispatch.mockClear(); + fetchLatestAnnouncement.mockReset(); + }); + + afterEach(() => vi.clearAllMocks()); + + it('shows the announcement once fetched when authenticated and unseen', async () => { + fetchLatestAnnouncement.mockResolvedValue(sample); + render(); + expect(await screen.findByTestId('modal')).toBeInTheDocument(); + expect(screen.getByText('Hello')).toBeInTheDocument(); + }); + + it('does not fetch or render when unauthenticated', async () => { + authState.isAuthenticated = false; + render(); + await waitFor(() => expect(fetchLatestAnnouncement).not.toHaveBeenCalled()); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('renders nothing when the fetched announcement was already shown', async () => { + shownIds = ['a1']; + fetchLatestAnnouncement.mockResolvedValue(sample); + render(); + await waitFor(() => expect(fetchLatestAnnouncement).toHaveBeenCalled()); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('renders nothing when there is no active announcement', async () => { + fetchLatestAnnouncement.mockResolvedValue(null); + render(); + await waitFor(() => expect(fetchLatestAnnouncement).toHaveBeenCalled()); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('records the announcement as shown and hides it on dismiss', async () => { + fetchLatestAnnouncement.mockResolvedValue(sample); + render(); + const dismiss = await screen.findByTestId('dismiss'); + dismiss.click(); + expect(dispatch).toHaveBeenCalledWith(markAnnouncementShown('a1')); + await waitFor(() => expect(screen.queryByTestId('modal')).not.toBeInTheDocument()); + }); + + it('swallows a fetch error without rendering', async () => { + fetchLatestAnnouncement.mockRejectedValue(new Error('boom')); + render(); + await waitFor(() => expect(fetchLatestAnnouncement).toHaveBeenCalled()); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/components/Announcement/AnnouncementGate.tsx b/app/src/components/Announcement/AnnouncementGate.tsx new file mode 100644 index 000000000..8bbc598bb --- /dev/null +++ b/app/src/components/Announcement/AnnouncementGate.tsx @@ -0,0 +1,74 @@ +import debugFactory from 'debug'; +import { useEffect, useRef, useState } from 'react'; + +import { useCoreState } from '../../providers/CoreStateProvider'; +import { type Announcement, fetchLatestAnnouncement } from '../../services/announcementService'; +import { markAnnouncementShown } from '../../store/announcementSlice'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import AnnouncementModal from './AnnouncementModal'; + +const log = debugFactory('announcement'); + +/** + * Fetches the latest active announcement once the user is authenticated and, + * if it hasn't been seen before (per-user, persisted), shows it once over the + * app. The modal sits at z-[9998] — just below the harness-init overlay + * (z-[9999]) — so during first-run setup the init screen covers it and the + * announcement becomes visible only once init finishes. + */ +export default function AnnouncementGate() { + const dispatch = useAppDispatch(); + const { snapshot } = useCoreState(); + const isAuthenticated = snapshot.auth.isAuthenticated; + const userId = snapshot.auth.userId ?? null; + const shownIds = useAppSelector(state => state.announcement.shownIds); + + const [announcement, setAnnouncement] = useState(null); + // One fetch per (user) auth session — keyed by userId so a user switch refetches. + const fetchedForRef = useRef(null); + + useEffect(() => { + if (!isAuthenticated) { + // Reset on sign-out so the next sign-in fetches again. The render guard + // below hides any stale announcement while signed out (no setState here). + fetchedForRef.current = null; + return; + } + + const key = userId ?? 'authenticated'; + if (fetchedForRef.current === key) { + return; + } + fetchedForRef.current = key; + + let cancelled = false; + void (async () => { + try { + const latest = await fetchLatestAnnouncement(); + if (cancelled || !latest) { + return; + } + setAnnouncement(latest); + log('fetched announcement %s', latest.id); + } catch (err) { + // A missing/failed announcement is never fatal — just don't show one. + log('fetch failed: %O', err); + } + })(); + + return () => { + cancelled = true; + }; + }, [isAuthenticated, userId]); + + if (!isAuthenticated || !announcement || shownIds.includes(announcement.id)) { + return null; + } + + const handleDismiss = () => { + dispatch(markAnnouncementShown(announcement.id)); + setAnnouncement(null); + }; + + return ; +} diff --git a/app/src/components/Announcement/AnnouncementModal.test.tsx b/app/src/components/Announcement/AnnouncementModal.test.tsx new file mode 100644 index 000000000..c4b8aa0a5 --- /dev/null +++ b/app/src/components/Announcement/AnnouncementModal.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { Announcement } from '../../services/announcementService'; +import { renderWithProviders } from '../../test/test-utils'; +import AnnouncementModal from './AnnouncementModal'; + +function announcement(overrides: Partial = {}): Announcement { + return { + id: 'a1', + title: 'Scheduled maintenance', + body: 'We will be down tonight.', + severity: 'INFO', + cta: null, + startsAt: null, + expiresAt: null, + createdAt: null, + ...overrides, + }; +} + +describe('AnnouncementModal', () => { + afterEach(() => vi.restoreAllMocks()); + + it('renders the title, body, and severity label', () => { + renderWithProviders(); + expect(screen.getByText('Scheduled maintenance')).toBeInTheDocument(); + expect(screen.getByText('We will be down tonight.')).toBeInTheDocument(); + expect(screen.getByText('Info')).toBeInTheDocument(); + }); + + it.each([ + ['INFO', 'Info'], + ['WARNING', 'Important'], + ['CRITICAL', 'Critical'], + ] as const)('maps severity %s to the %s badge', (severity, label) => { + renderWithProviders( + + ); + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + it('calls onDismiss when the dismiss button is clicked', () => { + const onDismiss = vi.fn(); + renderWithProviders(); + fireEvent.click(screen.getByTestId('announcement-dismiss')); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('renders no CTA button when there is no cta', () => { + renderWithProviders( + + ); + expect(screen.queryByTestId('announcement-cta')).not.toBeInTheDocument(); + }); + + it('opens the CTA externally and dismisses when the CTA is clicked', () => { + const onDismiss = vi.fn(); + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null); + renderWithProviders( + + ); + + const cta = screen.getByTestId('announcement-cta'); + expect(cta).toHaveTextContent('Read more'); + + fireEvent.click(cta); + expect(openSpy).toHaveBeenCalledWith('https://x.test/', '_blank', 'noopener,noreferrer'); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/components/Announcement/AnnouncementModal.tsx b/app/src/components/Announcement/AnnouncementModal.tsx new file mode 100644 index 000000000..726bf3f23 --- /dev/null +++ b/app/src/components/Announcement/AnnouncementModal.tsx @@ -0,0 +1,81 @@ +import { useT } from '../../lib/i18n/I18nContext'; +import type { Announcement, AnnouncementSeverity } from '../../services/announcementService'; + +interface AnnouncementModalProps { + announcement: Announcement; + onDismiss: () => void; +} + +// Severity drives the accent band + icon tint. Tokens mirror the app palette +// (primary/amber/coral) used elsewhere (see InitProgressScreen). +const severityAccent: Record = { + INFO: 'border-primary-500/30 bg-primary-500/10 text-primary-300', + WARNING: 'border-amber-500/30 bg-amber-500/10 text-amber-300', + CRITICAL: 'border-coral-500/30 bg-coral-500/10 text-coral-300', +}; + +const severityLabel: Record = { + INFO: 'Info', + WARNING: 'Important', + CRITICAL: 'Critical', +}; + +/** + * One-shot announcement banner shown over the app after harness init. Title, + * body, and the optional CTA are backend-provided (not i18n); only the dismiss + * affordance is localized. + */ +export default function AnnouncementModal({ announcement, onDismiss }: AnnouncementModalProps) { + const { t } = useT(); + const accent = severityAccent[announcement.severity]; + + const openCta = () => { + if (!announcement.cta) { + return; + } + // Open externally; never navigate the app shell to a backend-provided URL. + window.open(announcement.cta.url, '_blank', 'noopener,noreferrer'); + onDismiss(); + }; + + return ( +
+
+ + {severityLabel[announcement.severity]} + + +

+ {announcement.title} +

+

{announcement.body}

+ +
+ + {announcement.cta && ( + + )} +
+
+
+ ); +} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 2bbf695e2..7e79bdfa6 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -5630,6 +5630,8 @@ const messages: TranslationMap = { 'memory.tab.cohesion': 'Cohesion', 'harnessInit.title': 'جارٍ الإعداد', + + 'announcement.gotIt': 'حسناً', 'harnessInit.subtitle': 'يقوم OpenHuman بتجهيز المكونات التي يحتاجها عند التشغيل الأول.', 'harnessInit.stepPython': 'بيئة تشغيل Python', 'harnessInit.stepSpacy': 'النموذج اللغوي', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 21ff7d350..935dd3d65 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -5747,6 +5747,7 @@ const messages: TranslationMap = { // Keyring consent & security 'harnessInit.title': 'সেটআপ করা হচ্ছে', + 'announcement.gotIt': 'বুঝেছি', 'harnessInit.subtitle': 'প্রথম চালুর সময় OpenHuman প্রয়োজনীয় উপাদানগুলো প্রস্তুত করছে।', 'harnessInit.stepPython': 'Python রানটাইম', 'harnessInit.stepSpacy': 'ভাষা মডেল', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 41f2bbe09..89de9cd26 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -5893,6 +5893,8 @@ const messages: TranslationMap = { 'memory.tab.cohesion': 'Cohesion', 'harnessInit.title': 'Einrichtung läuft', + + 'announcement.gotIt': 'Verstanden', 'harnessInit.subtitle': 'OpenHuman bereitet beim ersten Start benötigte Komponenten vor.', 'harnessInit.stepPython': 'Python-Laufzeitumgebung', 'harnessInit.stepSpacy': 'Sprachmodell', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 53412ac6d..f51bda630 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -6271,6 +6271,7 @@ const en: TranslationMap = { // First-run initialization (harness_init) 'harnessInit.title': 'Setting things up', + 'announcement.gotIt': 'Got it', 'harnessInit.subtitle': 'OpenHuman is preparing components it needs on first launch.', 'harnessInit.stepPython': 'Python runtime', 'harnessInit.stepSpacy': 'Language model', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index f8b4aa53d..5453b3da7 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -5857,6 +5857,8 @@ const messages: TranslationMap = { 'memory.tab.cohesion': 'Cohesion', 'harnessInit.title': 'Preparando todo', + + 'announcement.gotIt': 'Entendido', 'harnessInit.subtitle': 'OpenHuman está preparando los componentes que necesita en el primer inicio.', 'harnessInit.stepPython': 'Entorno de ejecución de Python', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 3636e4b55..c1d32d862 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -5875,6 +5875,8 @@ const messages: TranslationMap = { 'memory.tab.cohesion': 'Cohesion', 'harnessInit.title': 'Configuration en cours', + + 'announcement.gotIt': 'Compris', 'harnessInit.subtitle': 'OpenHuman prépare les composants dont il a besoin lors du premier lancement.', 'harnessInit.stepPython': "Environnement d'exécution Python", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 56f61fc92..4ac9c13f6 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -5750,6 +5750,8 @@ const messages: TranslationMap = { 'memory.tab.cohesion': 'Cohesion', 'harnessInit.title': 'सेटअप किया जा रहा है', + + 'announcement.gotIt': 'समझ गया', 'harnessInit.subtitle': 'OpenHuman पहली बार शुरू होने पर आवश्यक घटक तैयार कर रहा है।', 'harnessInit.stepPython': 'Python रनटाइम', 'harnessInit.stepSpacy': 'भाषा मॉडल', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index e896cc4b3..c49afe3c6 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -5762,6 +5762,8 @@ const messages: TranslationMap = { 'memory.tab.cohesion': 'Cohesion', 'harnessInit.title': 'Menyiapkan semuanya', + + 'announcement.gotIt': 'Mengerti', 'harnessInit.subtitle': 'OpenHuman sedang menyiapkan komponen yang dibutuhkan saat pertama kali dijalankan.', 'harnessInit.stepPython': 'Runtime Python', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index d3a5e65d9..cc466b166 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -5842,6 +5842,8 @@ const messages: TranslationMap = { 'memory.tab.cohesion': 'Cohesion', 'harnessInit.title': 'Preparazione in corso', + + 'announcement.gotIt': 'Ho capito', 'harnessInit.subtitle': 'OpenHuman sta preparando i componenti necessari al primo avvio.', 'harnessInit.stepPython': 'Ambiente di esecuzione Python', 'harnessInit.stepSpacy': 'Modello linguistico', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index e23b41e3f..3077d5b08 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -5692,6 +5692,8 @@ const messages: TranslationMap = { 'memory.tab.cohesion': 'Cohesion', 'harnessInit.title': '설정하는 중', + + 'announcement.gotIt': '확인', 'harnessInit.subtitle': 'OpenHuman이 처음 실행에 필요한 구성 요소를 준비하고 있습니다.', 'harnessInit.stepPython': 'Python 런타임', 'harnessInit.stepSpacy': '언어 모델', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 2f2bdf210..50eed5d3a 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -5832,6 +5832,8 @@ const messages: TranslationMap = { 'chat.files.error.delete_failed': 'Nie udało się usunąć pliku. Prosimy spróbować ponownie.', 'harnessInit.title': 'Trwa konfiguracja', + + 'announcement.gotIt': 'Rozumiem', 'harnessInit.subtitle': 'OpenHuman przygotowuje komponenty potrzebne przy pierwszym uruchomieniu.', 'harnessInit.stepPython': 'Środowisko uruchomieniowe Python', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 29935cc5f..0a68f9f68 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -5838,6 +5838,8 @@ const messages: TranslationMap = { 'memory.tab.cohesion': 'Cohesion', 'harnessInit.title': 'Preparando tudo', + + 'announcement.gotIt': 'Entendi', 'harnessInit.subtitle': 'O OpenHuman está preparando os componentes necessários na primeira inicialização.', 'harnessInit.stepPython': 'Ambiente de execução Python', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index c82e15881..154c4fcd5 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -5801,6 +5801,8 @@ const messages: TranslationMap = { 'memory.tab.cohesion': 'Cohesion', 'harnessInit.title': 'Идёт настройка', + + 'announcement.gotIt': 'Понятно', 'harnessInit.subtitle': 'OpenHuman готовит компоненты, необходимые при первом запуске.', 'harnessInit.stepPython': 'Среда выполнения Python', 'harnessInit.stepSpacy': 'Языковая модель', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index a0a2281dc..ad0b44e2e 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -5458,6 +5458,8 @@ const messages: TranslationMap = { 'memory.tab.cohesion': 'Cohesion', 'harnessInit.title': '正在进行设置', + + 'announcement.gotIt': '知道了', 'harnessInit.subtitle': 'OpenHuman 正在准备首次启动所需的组件。', 'harnessInit.stepPython': 'Python 运行时', 'harnessInit.stepSpacy': '语言模型', diff --git a/app/src/services/announcementService.test.ts b/app/src/services/announcementService.test.ts new file mode 100644 index 000000000..dfecb27d1 --- /dev/null +++ b/app/src/services/announcementService.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { fetchLatestAnnouncement, parseAnnouncement } from './announcementService'; +import { callCoreRpc } from './coreRpcClient'; + +vi.mock('./coreRpcClient', () => ({ callCoreRpc: vi.fn() })); + +describe('parseAnnouncement', () => { + const valid = { + id: 'a1', + title: 'Heads up', + body: 'Maintenance tonight', + severity: 'WARNING', + cta: { label: 'Read more', url: 'https://x.test' }, + startsAt: '2030-01-01T00:00:00.000Z', + expiresAt: null, + createdAt: '2030-01-01T00:00:00.000Z', + }; + + it('parses a complete announcement', () => { + expect(parseAnnouncement(valid)).toEqual({ + id: 'a1', + title: 'Heads up', + body: 'Maintenance tonight', + severity: 'WARNING', + cta: { label: 'Read more', url: 'https://x.test' }, + startsAt: '2030-01-01T00:00:00.000Z', + expiresAt: null, + createdAt: '2030-01-01T00:00:00.000Z', + }); + }); + + it('accepts _id as the id (lean docs)', () => { + const { id: _omit, ...rest } = valid; + void _omit; + expect(parseAnnouncement({ ...rest, _id: 'a2' })?.id).toBe('a2'); + }); + + it('returns null for the empty object the backend sends when none is active', () => { + // parse_api_response_json collapses {success:true,data:null} -> {}. + expect(parseAnnouncement({})).toBeNull(); + }); + + it.each([null, undefined, 'nope', 42])('returns null for non-object %p', raw => { + expect(parseAnnouncement(raw)).toBeNull(); + }); + + it('returns null when title or body is missing', () => { + expect(parseAnnouncement({ id: 'a1', body: 'b' })).toBeNull(); + expect(parseAnnouncement({ id: 'a1', title: 't' })).toBeNull(); + }); + + it('falls back to INFO for an unknown severity', () => { + expect(parseAnnouncement({ id: 'a1', title: 't', body: 'b', severity: 'LOUD' })?.severity).toBe( + 'INFO' + ); + }); + + it('drops a partial CTA (label or url missing)', () => { + expect( + parseAnnouncement({ id: 'a1', title: 't', body: 'b', cta: { label: 'x' } })?.cta + ).toBeNull(); + expect( + parseAnnouncement({ id: 'a1', title: 't', body: 'b', cta: { url: 'https://x.test' } })?.cta + ).toBeNull(); + }); +}); + +describe('fetchLatestAnnouncement', () => { + it('calls the announcements RPC and parses the result', async () => { + vi.mocked(callCoreRpc).mockResolvedValueOnce({ id: 'a1', title: 't', body: 'b' }); + const result = await fetchLatestAnnouncement(); + expect(callCoreRpc).toHaveBeenCalledWith( + expect.objectContaining({ method: 'openhuman.announcements_get_latest' }) + ); + expect(result?.id).toBe('a1'); + }); + + it('returns null when the backend reports no active announcement', async () => { + vi.mocked(callCoreRpc).mockResolvedValueOnce({}); + expect(await fetchLatestAnnouncement()).toBeNull(); + }); +}); diff --git a/app/src/services/announcementService.ts b/app/src/services/announcementService.ts new file mode 100644 index 000000000..d1ffdec88 --- /dev/null +++ b/app/src/services/announcementService.ts @@ -0,0 +1,85 @@ +/** + * Announcement service + * + * Thin RPC wrapper around the core `announcements_get_latest` controller, plus + * parsing into a typed announcement. The core proxies the backend's + * `GET /announcements/latest`, which returns the announcement object or, when + * nothing qualifies, an empty/`null` payload. We treat "no string id" as "no + * announcement" so the UI never renders an empty banner. + */ +import { callCoreRpc } from './coreRpcClient'; + +export type AnnouncementSeverity = 'INFO' | 'WARNING' | 'CRITICAL'; + +export interface AnnouncementCta { + label: string; + url: string; +} + +export interface Announcement { + id: string; + title: string; + body: string; + severity: AnnouncementSeverity; + cta: AnnouncementCta | null; + startsAt: string | null; + expiresAt: string | null; + createdAt: string | null; +} + +const SEVERITIES: AnnouncementSeverity[] = ['INFO', 'WARNING', 'CRITICAL']; + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function parseCta(raw: unknown): AnnouncementCta | null { + if (!raw || typeof raw !== 'object') { + return null; + } + const data = raw as Record; + const label = asString(data.label); + const url = asString(data.url); + if (!label || !url) { + return null; + } + return { label, url }; +} + +export function parseAnnouncement(raw: unknown): Announcement | null { + if (!raw || typeof raw !== 'object') { + return null; + } + const data = raw as Record; + const id = asString(data.id) ?? asString(data._id); + const title = asString(data.title); + const body = asString(data.body); + // The backend collapses "no active announcement" to an empty object; without + // an id (and the core fields) there is nothing to show. + if (!id || !title || !body) { + return null; + } + const severity = asString(data.severity) as AnnouncementSeverity | null; + return { + id, + title, + body, + severity: severity && SEVERITIES.includes(severity) ? severity : 'INFO', + cta: parseCta(data.cta), + startsAt: asString(data.startsAt), + expiresAt: asString(data.expiresAt), + createdAt: asString(data.createdAt), + }; +} + +/** + * Fetch the latest active announcement, or null. Network/auth failures resolve + * to null — a missing announcement is never worth surfacing an error for. + */ +export async function fetchLatestAnnouncement(): Promise { + const payload = await callCoreRpc({ + method: 'openhuman.announcements_get_latest', + timeoutMs: 8000, + }); + return parseAnnouncement(payload); +} diff --git a/app/src/store/announcementSlice.test.ts b/app/src/store/announcementSlice.test.ts new file mode 100644 index 000000000..4b7fac24e --- /dev/null +++ b/app/src/store/announcementSlice.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import reducer, { type AnnouncementState, markAnnouncementShown } from './announcementSlice'; + +const initial: AnnouncementState = { shownIds: [] }; + +describe('announcementSlice', () => { + it('records a shown announcement id', () => { + const next = reducer(initial, markAnnouncementShown('a1')); + expect(next.shownIds).toEqual(['a1']); + }); + + it('does not duplicate an already-seen id', () => { + const once = reducer(initial, markAnnouncementShown('a1')); + const twice = reducer(once, markAnnouncementShown('a1')); + expect(twice.shownIds).toEqual(['a1']); + }); + + it('ignores an empty id', () => { + expect(reducer(initial, markAnnouncementShown('')).shownIds).toEqual([]); + }); + + it('caps the history at 200, dropping the oldest', () => { + let state = initial; + for (let i = 0; i < 205; i += 1) { + state = reducer(state, markAnnouncementShown(`id-${i}`)); + } + expect(state.shownIds).toHaveLength(200); + expect(state.shownIds[0]).toBe('id-5'); + expect(state.shownIds.at(-1)).toBe('id-204'); + }); +}); diff --git a/app/src/store/announcementSlice.ts b/app/src/store/announcementSlice.ts new file mode 100644 index 000000000..ba96ea31a --- /dev/null +++ b/app/src/store/announcementSlice.ts @@ -0,0 +1,36 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +/** + * Tracks which announcements this user has already seen so the harness-init + * banner shows each one exactly once. Persisted through `userScopedStorage` + * (see store/index.ts) so the seen set is per-user and survives reloads. + */ +export interface AnnouncementState { + shownIds: string[]; +} + +const initialState: AnnouncementState = { shownIds: [] }; + +const MAX_TRACKED = 200; + +const announcementSlice = createSlice({ + name: 'announcement', + initialState, + reducers: { + markAnnouncementShown(state, action: PayloadAction) { + const id = action.payload; + if (!id || state.shownIds.includes(id)) { + return; + } + state.shownIds.push(id); + // Cap the history so a long-lived install can't grow it unbounded; the + // oldest ids fall off first (they're the least likely to reappear). + if (state.shownIds.length > MAX_TRACKED) { + state.shownIds.splice(0, state.shownIds.length - MAX_TRACKED); + } + }, + }, +}); + +export const { markAnnouncementShown } = announcementSlice.actions; +export default announcementSlice.reducer; diff --git a/app/src/store/index.ts b/app/src/store/index.ts index 8d79df315..cb0019d41 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -15,6 +15,7 @@ import { import { E2E_RESTART_APP_AS_RELOAD, IS_DEV } from '../utils/config'; import accountsReducer from './accountsSlice'; import agentProfileReducer from './agentProfileSlice'; +import announcementReducer from './announcementSlice'; import { type ArtifactsByThread, filterArtifactsForPersist, @@ -208,6 +209,11 @@ const chatRuntimePersistConfig = { }; const persistedChatRuntimeReducer = persistReducer(chatRuntimePersistConfig, chatRuntimeReducer); +// Persist the set of announcement ids this user has already seen so the +// harness-init banner shows each announcement exactly once (user-scoped). +const announcementPersistConfig = { key: 'announcement', storage, whitelist: ['shownIds'] }; +const persistedAnnouncementReducer = persistReducer(announcementPersistConfig, announcementReducer); + export const store = configureStore({ reducer: { backendMeet: backendMeetReducer, @@ -228,6 +234,7 @@ export const store = configureStore({ persona: persistedPersonaReducer, theme: persistedThemeReducer, ptt: persistedPttReducer, + announcement: persistedAnnouncementReducer, // In-memory only (not persisted): survives route changes / background-job // completion, resets on restart + user switch. Durable storage is a #3931 // follow-up. diff --git a/src/core/all.rs b/src/core/all.rs index 3ccaf5da5..75dfc4552 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -247,6 +247,8 @@ fn build_registered_controllers() -> Vec { controllers.extend(crate::openhuman::referral::all_referral_registered_controllers()); // Billing and subscription management controllers.extend(crate::openhuman::billing::all_billing_registered_controllers()); + // Announcements surfaced on harness init + controllers.extend(crate::openhuman::announcements::all_announcements_registered_controllers()); // Team and role management controllers.extend(crate::openhuman::team::all_team_registered_controllers()); // E2E test support — `openhuman.test_reset` wipes sidecar state in-place. @@ -422,6 +424,7 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::redirect_links::all_redirect_links_controller_schemas()); schemas.extend(crate::openhuman::referral::all_referral_controller_schemas()); schemas.extend(crate::openhuman::billing::all_billing_controller_schemas()); + schemas.extend(crate::openhuman::announcements::all_announcements_controller_schemas()); schemas.extend(crate::openhuman::team::all_team_controller_schemas()); #[cfg(feature = "e2e-test-support")] schemas.extend(crate::openhuman::test_support::all_test_support_controller_schemas()); @@ -573,6 +576,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> { "Durable agent-team coordination: teams, members, dependency-aware task claiming, and teammate messaging.", ), "billing" => Some("Subscription plan, payment links, and credit top-up via the backend."), + "announcements" => { + Some("Latest active product announcement surfaced on harness init, via the backend.") + } "team" => Some("Team member management, invites, and role changes via the backend."), "tool_registry" => Some( "Read-only discovery for MCP stdio tools and controller-backed tools, including routes, schemas, version, allowed agents, and health.", diff --git a/src/openhuman/announcements/mod.rs b/src/openhuman/announcements/mod.rs new file mode 100644 index 000000000..3bc0cdd47 --- /dev/null +++ b/src/openhuman/announcements/mod.rs @@ -0,0 +1,14 @@ +//! Announcements RPC adapter — thin-wraps the hosted API. +//! +//! Exposes the latest active announcement for the signed-in user through the +//! standard controller registry (`openhuman.announcements_*`). The UI surfaces +//! it once on harness init and tracks dismissal locally by id. + +mod ops; +mod schemas; + +pub use ops::*; +pub use schemas::{ + all_announcements_controller_schemas, all_announcements_registered_controllers, + announcements_schemas, +}; diff --git a/src/openhuman/announcements/ops.rs b/src/openhuman/announcements/ops.rs new file mode 100644 index 000000000..11775a2db --- /dev/null +++ b/src/openhuman/announcements/ops.rs @@ -0,0 +1,39 @@ +//! Announcements RPC ops — a thin adapter that calls the hosted API. +//! +//! # Security +//! Requires a valid app-session JWT stored via `auth_store_session` (same guard +//! as `billing/ops.rs`). The JWT is sent as `Authorization: Bearer …`; the +//! backend decides what the user may see. No authorization is replicated here. +//! A lapsed session surfaces the backend 401 verbatim via `flatten_authed_error`. + +use reqwest::Method; +use serde_json::Value; + +use crate::api::config::effective_backend_api_url; +use crate::api::BackendOAuthClient; +use crate::openhuman::config::Config; +use crate::rpc::RpcOutcome; + +/// Canonical authed-session guard — rejects an expired token locally instead of +/// firing a doomed backend 401 (see `billing/ops.rs` / #3297). +fn require_token(config: &Config) -> Result { + crate::openhuman::credentials::session_support::require_live_session_token(config) +} + +async fn get_authed_value(config: &Config, method: Method, path: &str) -> Result { + let token = require_token(config)?; + let api_url = effective_backend_api_url(&config.api_url); + let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?; + client + .authed_json(&token, method, path, None) + .await + .map_err(crate::api::flatten_authed_error) +} + +/// Fetch the latest active announcement for the signed-in user. +/// Maps to `GET /announcements/latest`. The backend returns the announcement +/// object or `null` when nothing qualifies; both pass through verbatim. +pub async fn get_latest_announcement(config: &Config) -> Result, String> { + let data = get_authed_value(config, Method::GET, "/announcements/latest").await?; + Ok(RpcOutcome::single_log(data, "latest announcement fetched")) +} diff --git a/src/openhuman/announcements/schemas.rs b/src/openhuman/announcements/schemas.rs new file mode 100644 index 000000000..891f5419f --- /dev/null +++ b/src/openhuman/announcements/schemas.rs @@ -0,0 +1,91 @@ +use serde_json::{Map, Value}; + +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; +use crate::openhuman::config::rpc as config_rpc; +use crate::rpc::RpcOutcome; + +pub fn all_announcements_controller_schemas() -> Vec { + vec![announcements_schemas("announcements_get_latest")] +} + +pub fn all_announcements_registered_controllers() -> Vec { + vec![RegisteredController { + schema: announcements_schemas("announcements_get_latest"), + handler: handle_announcements_get_latest, + }] +} + +pub fn announcements_schemas(function: &str) -> ControllerSchema { + match function { + "announcements_get_latest" => ControllerSchema { + namespace: "announcements", + function: "get_latest", + description: "Fetch the latest active announcement for the signed-in user (or null).", + inputs: vec![], + outputs: vec![json_output( + "announcement", + "Latest active announcement from backend /announcements/latest, or null when none.", + )], + }, + _ => ControllerSchema { + namespace: "announcements", + function: "unknown", + description: "Unknown announcements controller.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "error", + ty: TypeSchema::String, + comment: "Lookup error details.", + required: true, + }], + }, + } +} + +fn handle_announcements_get_latest(_params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + to_json(crate::openhuman::announcements::get_latest_announcement(&config).await?) + }) +} + +fn to_json(outcome: RpcOutcome) -> Result { + outcome.into_cli_compatible_json() +} + +fn json_output(name: &'static str, comment: &'static str) -> FieldSchema { + FieldSchema { + name, + ty: TypeSchema::Json, + comment, + required: true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_latest_schema_has_expected_namespace_and_no_inputs() { + let schema = announcements_schemas("announcements_get_latest"); + assert_eq!(schema.namespace, "announcements"); + assert_eq!(schema.function, "get_latest"); + assert!(schema.inputs.is_empty()); + assert_eq!(schema.outputs.len(), 1); + assert_eq!(schema.outputs[0].name, "announcement"); + } + + #[test] + fn unknown_function_falls_back() { + let schema = announcements_schemas("nope"); + assert_eq!(schema.function, "unknown"); + } + + #[test] + fn registers_exactly_one_controller() { + assert_eq!(all_announcements_registered_controllers().len(), 1); + assert_eq!(all_announcements_controller_schemas().len(), 1); + } +} diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index a5fafce3a..92cdd4ddf 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -24,6 +24,7 @@ pub mod agent_orchestration; pub mod agent_registry; pub mod agent_tool_policy; pub mod agentbox; +pub mod announcements; pub mod app_state; pub mod approval; pub mod artifacts;