mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(announcements): show latest announcement once on harness init (#4125)
This commit is contained in:
@@ -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 && <AppUpdatePrompt />}
|
||||
<KeyringConsentOverlay />
|
||||
<HarnessInitOverlay />
|
||||
<AnnouncementGate />
|
||||
<SecretPromptDialog />
|
||||
</ServiceBlockingGate>
|
||||
</CommandProvider>
|
||||
|
||||
@@ -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 }) => (
|
||||
<div data-testid="modal">
|
||||
<span>{announcement.title}</span>
|
||||
<button type="button" data-testid="dismiss" onClick={onDismiss}>
|
||||
dismiss
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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(<AnnouncementGate />);
|
||||
expect(await screen.findByTestId('modal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not fetch or render when unauthenticated', async () => {
|
||||
authState.isAuthenticated = false;
|
||||
render(<AnnouncementGate />);
|
||||
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(<AnnouncementGate />);
|
||||
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(<AnnouncementGate />);
|
||||
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(<AnnouncementGate />);
|
||||
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(<AnnouncementGate />);
|
||||
await waitFor(() => expect(fetchLatestAnnouncement).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<Announcement | null>(null);
|
||||
// One fetch per (user) auth session — keyed by userId so a user switch refetches.
|
||||
const fetchedForRef = useRef<string | null>(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 <AnnouncementModal announcement={announcement} onDismiss={handleDismiss} />;
|
||||
}
|
||||
@@ -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> = {}): 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(<AnnouncementModal announcement={announcement()} onDismiss={vi.fn()} />);
|
||||
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(
|
||||
<AnnouncementModal announcement={announcement({ severity })} onDismiss={vi.fn()} />
|
||||
);
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onDismiss when the dismiss button is clicked', () => {
|
||||
const onDismiss = vi.fn();
|
||||
renderWithProviders(<AnnouncementModal announcement={announcement()} onDismiss={onDismiss} />);
|
||||
fireEvent.click(screen.getByTestId('announcement-dismiss'));
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders no CTA button when there is no cta', () => {
|
||||
renderWithProviders(
|
||||
<AnnouncementModal announcement={announcement({ cta: null })} onDismiss={vi.fn()} />
|
||||
);
|
||||
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(
|
||||
<AnnouncementModal
|
||||
announcement={announcement({ cta: { label: 'Read more', url: 'https://x.test/' } })}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<AnnouncementSeverity, string> = {
|
||||
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<AnnouncementSeverity, string> = {
|
||||
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 (
|
||||
<div
|
||||
className="fixed inset-0 z-[9998] flex items-center justify-center bg-stone-950/90 p-4 backdrop-blur-sm"
|
||||
data-testid="announcement-overlay">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="announcement-title"
|
||||
className="w-full max-w-md rounded-2xl border border-stone-700/60 bg-stone-900 p-6 shadow-2xl">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-[11px] font-medium ${accent}`}>
|
||||
{severityLabel[announcement.severity]}
|
||||
</span>
|
||||
|
||||
<h2 id="announcement-title" className="mt-3 text-lg font-semibold text-white">
|
||||
{announcement.title}
|
||||
</h2>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm text-stone-300">{announcement.body}</p>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
data-testid="announcement-dismiss"
|
||||
className="rounded-lg border border-stone-700 px-3 py-1.5 text-sm text-stone-300 hover:bg-stone-800 hover:text-white">
|
||||
{t('announcement.gotIt')}
|
||||
</button>
|
||||
{announcement.cta && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCta}
|
||||
data-testid="announcement-cta"
|
||||
className="rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-500/90">
|
||||
{announcement.cta.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5630,6 +5630,8 @@ const messages: TranslationMap = {
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
'harnessInit.title': 'جارٍ الإعداد',
|
||||
|
||||
'announcement.gotIt': 'حسناً',
|
||||
'harnessInit.subtitle': 'يقوم OpenHuman بتجهيز المكونات التي يحتاجها عند التشغيل الأول.',
|
||||
'harnessInit.stepPython': 'بيئة تشغيل Python',
|
||||
'harnessInit.stepSpacy': 'النموذج اللغوي',
|
||||
|
||||
@@ -5747,6 +5747,7 @@ const messages: TranslationMap = {
|
||||
|
||||
// Keyring consent & security
|
||||
'harnessInit.title': 'সেটআপ করা হচ্ছে',
|
||||
'announcement.gotIt': 'বুঝেছি',
|
||||
'harnessInit.subtitle': 'প্রথম চালুর সময় OpenHuman প্রয়োজনীয় উপাদানগুলো প্রস্তুত করছে।',
|
||||
'harnessInit.stepPython': 'Python রানটাইম',
|
||||
'harnessInit.stepSpacy': 'ভাষা মডেল',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5750,6 +5750,8 @@ const messages: TranslationMap = {
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
'harnessInit.title': 'सेटअप किया जा रहा है',
|
||||
|
||||
'announcement.gotIt': 'समझ गया',
|
||||
'harnessInit.subtitle': 'OpenHuman पहली बार शुरू होने पर आवश्यक घटक तैयार कर रहा है।',
|
||||
'harnessInit.stepPython': 'Python रनटाइम',
|
||||
'harnessInit.stepSpacy': 'भाषा मॉडल',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5692,6 +5692,8 @@ const messages: TranslationMap = {
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
'harnessInit.title': '설정하는 중',
|
||||
|
||||
'announcement.gotIt': '확인',
|
||||
'harnessInit.subtitle': 'OpenHuman이 처음 실행에 필요한 구성 요소를 준비하고 있습니다.',
|
||||
'harnessInit.stepPython': 'Python 런타임',
|
||||
'harnessInit.stepSpacy': '언어 모델',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5801,6 +5801,8 @@ const messages: TranslationMap = {
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
'harnessInit.title': 'Идёт настройка',
|
||||
|
||||
'announcement.gotIt': 'Понятно',
|
||||
'harnessInit.subtitle': 'OpenHuman готовит компоненты, необходимые при первом запуске.',
|
||||
'harnessInit.stepPython': 'Среда выполнения Python',
|
||||
'harnessInit.stepSpacy': 'Языковая модель',
|
||||
|
||||
@@ -5458,6 +5458,8 @@ const messages: TranslationMap = {
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
'harnessInit.title': '正在进行设置',
|
||||
|
||||
'announcement.gotIt': '知道了',
|
||||
'harnessInit.subtitle': 'OpenHuman 正在准备首次启动所需的组件。',
|
||||
'harnessInit.stepPython': 'Python 运行时',
|
||||
'harnessInit.stepSpacy': '语言模型',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<Announcement | null> {
|
||||
const payload = await callCoreRpc<unknown>({
|
||||
method: 'openhuman.announcements_get_latest',
|
||||
timeoutMs: 8000,
|
||||
});
|
||||
return parseAnnouncement(payload);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string>) {
|
||||
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;
|
||||
@@ -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.
|
||||
|
||||
@@ -247,6 +247,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
|
||||
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<ControllerSchema> {
|
||||
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.",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<String, String> {
|
||||
crate::openhuman::credentials::session_support::require_live_session_token(config)
|
||||
}
|
||||
|
||||
async fn get_authed_value(config: &Config, method: Method, path: &str) -> Result<Value, String> {
|
||||
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<RpcOutcome<Value>, String> {
|
||||
let data = get_authed_value(config, Method::GET, "/announcements/latest").await?;
|
||||
Ok(RpcOutcome::single_log(data, "latest announcement fetched"))
|
||||
}
|
||||
@@ -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<ControllerSchema> {
|
||||
vec![announcements_schemas("announcements_get_latest")]
|
||||
}
|
||||
|
||||
pub fn all_announcements_registered_controllers() -> Vec<RegisteredController> {
|
||||
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<String, Value>) -> 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<Value>) -> Result<Value, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user