Files
openhuman/app/src/components/OpenhumanLinkModal.tsx
T

617 lines
23 KiB
TypeScript

/**
* Modal popped open when an `<openhuman-link path="...">` pill is clicked
* inside an agent message bubble.
*
* The pill dispatches a `window` `CustomEvent('openhuman-link', { detail: { path } })`;
* this component listens for it, opens the modal, and routes to a focused
* mini-flow per path. Keeps the chat in view (no react-router navigation)
* so the user can complete the action and return to the agent without
* losing the conversation.
*
* Mounted once at AppShell root.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useChannelDefinitions } from '../hooks/useChannelDefinitions';
import { useT } from '../lib/i18n/I18nContext';
import {
ensureNotificationPermission,
getNotificationPermissionState,
type NotificationPermissionState,
showNativeNotification,
} from '../lib/nativeNotifications/tauriBridge';
import { isTauri, purgeWebviewAccount } from '../services/webviewAccountService';
import { addAccount, removeAccount, setActiveAccount } from '../store/accountsSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import {
type Account,
type AccountProvider,
type AccountStatus,
PROVIDERS,
} from '../types/accounts';
import { BILLING_DASHBOARD_URL, DISCORD_INVITE_URL } from '../utils/links';
import { openUrl } from '../utils/openUrl';
import { ProviderIcon } from './accounts/providerIcons';
import ChannelSetupModal from './channels/ChannelSetupModal';
interface OpenhumanLinkEvent {
path: string;
}
export const OPENHUMAN_LINK_EVENT = 'openhuman-link';
const ALLOWED_PATHS = [
'settings/notifications',
'settings/billing',
'settings/messaging',
'community/discord',
'community/discord-report',
'accounts/setup',
] as const;
type AllowedPath = (typeof ALLOWED_PATHS)[number];
const ALLOWED_PATHS_SET = new Set<string>(ALLOWED_PATHS);
const OpenhumanLinkModal = () => {
const { t } = useT();
const [activePath, setActivePath] = useState<AllowedPath | null>(null);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<OpenhumanLinkEvent>).detail;
if (detail?.path && ALLOWED_PATHS_SET.has(detail.path)) {
setActivePath(detail.path as AllowedPath);
}
};
window.addEventListener(OPENHUMAN_LINK_EVENT, handler);
return () => window.removeEventListener(OPENHUMAN_LINK_EVENT, handler);
}, []);
const close = useCallback(() => setActivePath(null), []);
if (!activePath) return null;
// Telegram (and any future channel) gets the dedicated `ChannelSetupModal`
// already used by Skills + Settings instead of a bespoke body wrapper.
// It manages its own portal + backdrop, so render it standalone.
if (activePath === 'settings/messaging') {
return <MessagingSetupBridge onClose={close} />;
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={close}
role="dialog"
aria-modal="true">
<div
className="w-full max-w-md rounded-2xl bg-white dark:bg-neutral-900 shadow-xl overflow-hidden"
onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-stone-100 dark:border-neutral-800 px-5 py-3">
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{titleForPath(activePath, t)}
</h2>
<button
type="button"
onClick={close}
aria-label={t('common.close')}
className="rounded p-1 text-stone-500 dark:text-neutral-400 hover:bg-stone-100 dark:hover:bg-neutral-800/60 hover:text-stone-800 dark:hover:text-neutral-100">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 6l12 12M6 18L18 6"
/>
</svg>
</button>
</div>
<div className="p-5">{renderBody(activePath, close)}</div>
</div>
</div>
);
};
/**
* Resolves the Telegram channel definition and hands it to the shared
* `ChannelSetupModal` (same component the Settings → Messaging panel
* uses). When definitions are still loading we render a tiny placeholder
* so the user gets feedback instead of a flashing screen.
*/
const MessagingSetupBridge = ({ onClose }: { onClose: () => void }) => {
const { t } = useT();
const { definitions, loading } = useChannelDefinitions();
const telegram = useMemo(() => definitions.find(d => d.id === 'telegram') ?? null, [definitions]);
if (loading && !telegram) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="rounded-2xl bg-white dark:bg-neutral-900 px-6 py-4 text-sm text-stone-600 dark:text-neutral-300 shadow-xl">
{t('app.openhumanLink.loadingChannelSetup')}
</div>
</div>
);
}
if (!telegram) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onClose}>
<div
className="rounded-2xl bg-white dark:bg-neutral-900 p-6 text-sm text-stone-700 dark:text-neutral-200 shadow-xl max-w-sm"
onClick={e => e.stopPropagation()}>
<p>{t('app.openhumanLink.telegramUnavailable')}</p>
<div className="mt-3 flex justify-end">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60">
{t('common.close')}
</button>
</div>
</div>
</div>
);
}
return <ChannelSetupModal definition={telegram} onClose={onClose} />;
};
function titleForPath(path: AllowedPath, t: (k: string) => string): string {
switch (path) {
case 'settings/notifications':
return t('app.openhumanLink.title.notifications');
case 'settings/billing':
return t('app.openhumanLink.title.billing');
case 'settings/messaging':
return t('app.openhumanLink.title.messaging');
case 'community/discord':
return t('app.openhumanLink.title.discord');
case 'community/discord-report':
return t('app.openhumanLink.title.discordReport');
case 'accounts/setup':
return t('app.openhumanLink.title.accounts');
}
}
function renderBody(path: AllowedPath, close: () => void) {
switch (path) {
case 'settings/notifications':
return <NotificationsBody close={close} />;
case 'settings/billing':
return <BillingBody close={close} />;
case 'settings/messaging':
// Routed via the dedicated `MessagingSetupBridge` above; this case
// is kept to satisfy the path-completeness check but is unreachable
// because the parent component returns the bridge before calling
// `renderBody`.
return null;
case 'community/discord':
return <DiscordBody close={close} />;
case 'community/discord-report':
return <DiscordReportBody close={close} />;
case 'accounts/setup':
return <AccountsSetupBody close={close} />;
}
}
// ── Notifications ────────────────────────────────────────────────────────
const NotificationsBody = ({ close }: { close: () => void }) => {
const { t } = useT();
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const [permissionState, setPermissionState] = useState<NotificationPermissionState>('unknown');
useEffect(() => {
let mounted = true;
void getNotificationPermissionState({ requestIfNeeded: false }).then(next => {
if (!mounted) return;
setPermissionState(next);
});
return () => {
mounted = false;
};
}, []);
const handleAllow = async () => {
if (status === 'sending') {
return;
}
setStatus('sending');
setError(null);
try {
if (!isTauri()) {
setStatus('error');
setError(t('app.openhumanLink.notifications.desktopOnly'));
return;
}
const granted = await ensureNotificationPermission();
if (!granted) {
const nextState = await getNotificationPermissionState({ requestIfNeeded: false });
setPermissionState(nextState);
setStatus('error');
setError(t('app.openhumanLink.notifications.permissionOff'));
return;
}
const sendResult = await showNativeNotification({
title: t('app.openhumanLink.notifications.welcomeTitle'),
body: t('app.openhumanLink.notifications.welcomeBody'),
tag: 'welcome-notification-test',
});
if (!sendResult.delivered) {
setStatus('error');
setError(sendResult.error ?? t('app.openhumanLink.notifications.triggerFailed'));
return;
}
setPermissionState('granted');
setStatus('sent');
} catch (e) {
setStatus('error');
setError(e instanceof Error ? e.message : String(e));
}
};
return (
<div className="space-y-4 text-sm text-stone-700 dark:text-neutral-200">
<p>{t('app.openhumanLink.notifications.intro')}</p>
{permissionState === 'denied' && (
<div className="rounded-xl border border-coral-200 bg-coral-50 dark:bg-coral-500/15 p-3 text-xs text-coral-700 dark:text-coral-300">
{t('app.openhumanLink.notifications.blocked')}
<br />
{t('app.openhumanLink.notifications.blockedStep1')}
<br />
{t('app.openhumanLink.notifications.blockedStep2')}
<br />
{t('app.openhumanLink.notifications.blockedStep3')}
</div>
)}
{(permissionState === 'prompt' || permissionState === 'unknown') && (
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3 text-xs text-stone-700 dark:text-neutral-200">
{t('app.openhumanLink.notifications.promptHint')}
</div>
)}
<button
type="button"
onClick={() => void handleAllow()}
disabled={status === 'sending'}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors disabled:opacity-60">
{status === 'sending'
? t('app.openhumanLink.notifications.asking')
: status === 'error'
? t('app.openhumanLink.notifications.retry')
: t('app.openhumanLink.notifications.send')}
</button>
{status === 'sent' && (
<p className="text-xs text-sage-700">{t('app.openhumanLink.notifications.sent')}</p>
)}
{status === 'error' && (
<p className="text-xs text-coral-600">
{t('app.openhumanLink.notifications.sendFailed').replace('{error}', error ?? '')}
</p>
)}
<DoneFooter close={close} />
</div>
);
};
// ── Billing ──────────────────────────────────────────────────────────────
const BillingBody = ({ close }: { close: () => void }) => {
const { t } = useT();
return (
<div className="space-y-4 text-sm text-stone-700 dark:text-neutral-200">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4">
<p className="text-xs uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('app.openhumanLink.billing.trialCredit')}
</p>
<p className="mt-1 text-2xl font-semibold text-stone-900 dark:text-neutral-100">
{t('onboarding.runtimeChoice.cloud.creditHighlight')}
</p>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{t('app.openhumanLink.billing.trialDesc')}
</p>
</div>
<button
type="button"
onClick={() => {
void openUrl(BILLING_DASHBOARD_URL).catch(() => {});
}}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors">
{t('app.openhumanLink.billing.openDashboard')}
</button>
<DoneFooter close={close} skipLabel={t('app.openhumanLink.billing.stayOnTrial')} />
</div>
);
};
// ── Discord ──────────────────────────────────────────────────────────────
const DiscordBody = ({ close }: { close: () => void }) => {
const { t } = useT();
return (
<div className="space-y-4 text-sm text-stone-700 dark:text-neutral-200">
<p>{t('app.openhumanLink.discord.intro')}</p>
<ul className="space-y-1.5 text-xs text-stone-600 dark:text-neutral-300 pl-1">
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-primary-400 flex-shrink-0" />
{t('app.openhumanLink.discord.perk1')}
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-primary-400 flex-shrink-0" />
{t('app.openhumanLink.discord.perk2')}
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-primary-400 flex-shrink-0" />
{t('app.openhumanLink.discord.perk3')}
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-primary-400 flex-shrink-0" />
{t('app.openhumanLink.discord.perk4')}
</li>
</ul>
<button
type="button"
onClick={async () => {
try {
await openUrl(DISCORD_INVITE_URL);
} catch {
// Ignore launcher errors from OS URL handler failures.
}
}}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors">
{t('app.openhumanLink.discord.openInvite')}
</button>
<DoneFooter close={close} skipLabel={t('app.openhumanLink.maybeLater')} />
</div>
);
};
/**
* Error-report variant of the Discord modal. Shown when an agent error pill
* with path "community/discord-report" is clicked. Distinct from DiscordBody
* (join-community flow):
* - Leads with an apology/acknowledgement copy.
* - Offers an "Open Discord" primary button to jump straight to the server
* (and closes the modal).
*/
const DiscordReportBody = ({ close }: { close: () => void }) => {
const { t } = useT();
return (
<div className="space-y-4 text-sm text-stone-700 dark:text-neutral-200">
<p>{t('app.openhumanLink.discordReport.intro')}</p>
<button
type="button"
onClick={async () => {
try {
await openUrl(DISCORD_INVITE_URL);
} finally {
close();
}
}}
className="w-full rounded-xl bg-primary-500 px-3 py-2.5 text-sm font-medium text-white hover:bg-primary-600 transition-colors">
{t('app.openhumanLink.discordReport.openDiscord')}
</button>
</div>
);
};
// ── Accounts setup (multi-channel toggle list) ──────────────────────────
/**
* Curated list of providers shown in the welcome flow's "Connect your apps"
* step. Excludes call-only surfaces (`google-meet`, `zoom`) and dev-only
* (`browserscan`) — those still appear in the full Add Account modal but
* aren't a "set this up during onboarding" target.
*/
const ACCOUNTS_SETUP_PROVIDERS: readonly AccountProvider[] = [
'whatsapp',
'wechat',
'telegram',
'slack',
'discord',
'linkedin',
];
function makeAccountId(): string {
const c = globalThis.crypto;
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
if (c && typeof c.getRandomValues === 'function') {
const bytes = new Uint8Array(4);
c.getRandomValues(bytes);
const suffix = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
return `acct-${Date.now().toString(36)}-${suffix}`;
}
return `acct-${Date.now().toString(36)}`;
}
/**
* Translation key + color for a given account lifecycle status. Returns a
* `labelKey` (not a literal) so the caller can localize it via `useT()` —
* this is a module-level helper with no hook scope of its own.
*/
export function statusDisplay(status: AccountStatus): { labelKey: string; dotClass: string } {
switch (status) {
case 'open':
return { labelKey: 'app.openhumanLink.status.connected', dotClass: 'bg-emerald-500' };
case 'loading':
return { labelKey: 'app.openhumanLink.status.loading', dotClass: 'bg-amber-400' };
case 'pending':
return { labelKey: 'app.openhumanLink.status.needsSignIn', dotClass: 'bg-amber-400' };
case 'timeout':
return { labelKey: 'app.openhumanLink.status.timedOut', dotClass: 'bg-red-400' };
case 'error':
return { labelKey: 'app.openhumanLink.status.error', dotClass: 'bg-red-400' };
case 'closed':
return { labelKey: 'app.openhumanLink.status.closed', dotClass: 'bg-stone-300' };
}
}
const AccountsSetupBody = ({ close }: { close: () => void }) => {
const { t } = useT();
const dispatch = useAppDispatch();
const accountsById = useAppSelector(s => s.accounts.accounts);
const order = useAppSelector(s => s.accounts.order);
// Track accounts added during this modal session so "Done" can navigate.
// Uses state (not ref) so the CTA label re-renders when toggles change.
const [newlyAdded, setNewlyAdded] = useState<Map<string, string>>(new Map());
// Map provider → first existing account (one provider, one row).
const accountByProvider = useMemo(() => {
const map = new Map<AccountProvider, Account>();
for (const id of order) {
const acct = accountsById[id];
if (acct && !map.has(acct.provider)) map.set(acct.provider, acct);
}
return map;
}, [accountsById, order]);
const providerDescriptors = useMemo(
() =>
ACCOUNTS_SETUP_PROVIDERS.map(id => PROVIDERS.find(p => p.id === id)).filter(
Boolean
) as typeof PROVIDERS,
[]
);
const handleToggle = (providerId: AccountProvider, label: string, currentlyOn: boolean) => {
if (currentlyOn) {
const existing = accountByProvider.get(providerId);
if (!existing) return;
void purgeWebviewAccount(existing.id).catch(() => {});
setNewlyAdded(prev => {
const next = new Map(prev);
next.delete(existing.id);
return next;
});
dispatch(removeAccount({ accountId: existing.id }));
return;
}
const acct: Account = {
id: makeAccountId(),
provider: providerId,
label,
createdAt: new Date().toISOString(),
status: 'pending',
};
setNewlyAdded(prev => new Map(prev).set(acct.id, label));
dispatch(addAccount(acct));
};
const handleDone = () => {
close();
// Activate the first newly-added account so the shell-level WebviewHost
// opens the auth flow immediately without mutating the current route.
const firstNew = [...newlyAdded.keys()][0];
if (firstNew) {
dispatch(setActiveAccount(firstNew));
}
};
// Dynamic CTA based on what's been toggled on
const firstNewLabel = [...newlyAdded.values()][0];
const doneLabel = firstNewLabel
? t('app.openhumanLink.accounts.continueWith').replace('{label}', firstNewLabel)
: t('app.openhumanLink.accounts.done');
return (
<div className="space-y-4 text-sm text-stone-700 dark:text-neutral-200">
<p>{t('app.openhumanLink.accounts.intro')}</p>
<div className="space-y-2">
{providerDescriptors.map(p => {
const acct = accountByProvider.get(p.id);
const on = !!acct;
const status = acct?.status;
return (
<div
key={p.id}
className="flex items-center gap-3 rounded-xl border border-stone-100 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
<ProviderIcon provider={p.id} className="h-5 w-5 flex-none" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{p.label}
</div>
{on && status ? (
<div className="flex items-center gap-1.5">
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${statusDisplay(status).dotClass}`}
/>
<span className="text-xs text-stone-500 dark:text-neutral-400">
{t(statusDisplay(status).labelKey)}
</span>
</div>
) : (
<p className="line-clamp-1 text-xs text-stone-500 dark:text-neutral-400">
{p.description}
</p>
)}
</div>
<button
type="button"
role="switch"
aria-checked={on}
aria-label={`${on ? t('skills.disconnect') : t('skills.connect')} ${p.label}`}
onClick={() => handleToggle(p.id, p.label, on)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
on ? 'bg-primary-500' : 'bg-stone-200'
}`}>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
on ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
</div>
);
})}
</div>
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('app.openhumanLink.accounts.webviewNote')}
</p>
<DoneFooter close={close} onDone={handleDone} doneLabel={doneLabel} />
</div>
);
};
// ── Shared footer ────────────────────────────────────────────────────────
const DoneFooter = ({
close,
onDone,
doneLabel,
skipLabel,
}: {
close: () => void;
onDone?: () => void;
doneLabel?: string;
skipLabel?: string;
}) => {
const { t } = useT();
const resolvedDone = doneLabel ?? t('app.openhumanLink.done');
const resolvedSkip = skipLabel ?? t('app.openhumanLink.skipForNow');
return (
<div className="flex items-center justify-between gap-3 pt-1">
<button
type="button"
onClick={close}
className="text-xs font-medium text-stone-500 dark:text-neutral-400 hover:text-stone-800 dark:hover:text-neutral-100">
{resolvedSkip}
</button>
<button
type="button"
onClick={onDone ?? close}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60">
{resolvedDone}
</button>
</div>
);
};
export default OpenhumanLinkModal;