mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
import createDebug from 'debug';
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
import { useT } from '../../../lib/i18n/I18nContext';
|
|
import { callCoreRpc } from '../../../services/coreRpcClient';
|
|
import type { ToastNotification } from '../../../types/intelligence';
|
|
import { ToastContainer } from '../../intelligence/Toast';
|
|
import Button from '../../ui/Button';
|
|
import {
|
|
SettingsBadge,
|
|
SettingsEmptyState,
|
|
SettingsListItem,
|
|
SettingsSection,
|
|
SettingsStatusLine,
|
|
} from '../controls';
|
|
import SettingsPanel from '../layout/SettingsPanel';
|
|
import PairPhoneModal from './devices/PairPhoneModal';
|
|
|
|
const log = createDebug('app:devices-ui');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types (mirror the Rust types.rs)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface PairedDevice {
|
|
channel_id: string;
|
|
label: string;
|
|
device_pubkey: string;
|
|
created_at: string;
|
|
last_seen_at: string | null;
|
|
peer_online: boolean | null;
|
|
revoked: boolean;
|
|
}
|
|
|
|
interface ListDevicesResponse {
|
|
devices: PairedDevice[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function truncateId(id: string): string {
|
|
if (id.length <= 10) return id;
|
|
return `${id.slice(0, 4)}…${id.slice(-4)}`;
|
|
}
|
|
|
|
function relativeTime(iso: string | null): string {
|
|
if (!iso) return 'Never';
|
|
const delta = Date.now() - new Date(iso).getTime();
|
|
const minutes = Math.floor(delta / 60_000);
|
|
if (minutes < 1) return 'Just now';
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
return `${Math.floor(hours / 24)}d ago`;
|
|
}
|
|
|
|
function formatRelativeTime(value: string, t: (key: string) => string): string {
|
|
if (value === 'Never') return t('devices.lastSeenNever');
|
|
if (value === 'Just now') return t('devices.lastSeenNow');
|
|
|
|
const match = value.match(/^(\d+)([mhd]) ago$/);
|
|
if (!match) return value;
|
|
|
|
const [, count, unit] = match;
|
|
const key =
|
|
unit === 'm'
|
|
? 'devices.lastSeenMinutes'
|
|
: unit === 'h'
|
|
? 'devices.lastSeenHours'
|
|
: 'devices.lastSeenDays';
|
|
|
|
return t(key).replace('{count}', count);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sub-components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function PeerDot({ online }: { online: boolean | null }) {
|
|
const { t } = useT();
|
|
const isOnline = online === true;
|
|
return (
|
|
<span
|
|
title={isOnline ? t('devices.online') : t('devices.offline')}
|
|
data-testid={isOnline ? 'peer-status-online' : 'peer-status-offline'}
|
|
className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${isOnline ? 'bg-sage-500' : 'bg-neutral-300'}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function DeviceRow({
|
|
device,
|
|
onRevoke,
|
|
}: {
|
|
device: PairedDevice;
|
|
onRevoke: (device: PairedDevice) => void;
|
|
}) {
|
|
const { t } = useT();
|
|
const statusBadge = (
|
|
<div className="flex items-center gap-1.5">
|
|
<PeerDot online={device.peer_online} />
|
|
<span className="font-mono text-xs text-neutral-400">{truncateId(device.channel_id)}</span>
|
|
<span className="text-xs text-neutral-400">
|
|
{formatRelativeTime(relativeTime(device.last_seen_at), t)}
|
|
</span>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<SettingsListItem
|
|
label={device.label}
|
|
badge={statusBadge}
|
|
onRemove={() => onRevoke(device)}
|
|
removeLabel={t('devices.revoke')}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ConfirmRevokeDialog({
|
|
device,
|
|
onConfirm,
|
|
onCancel,
|
|
}: {
|
|
device: PairedDevice;
|
|
onConfirm: () => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const { t } = useT();
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30">
|
|
<div className="bg-white dark:bg-neutral-900 rounded-2xl max-w-sm w-full p-6 border border-neutral-200 dark:border-neutral-800 shadow-large">
|
|
<h3 className="text-base font-semibold text-neutral-800 dark:text-neutral-100 mb-2">
|
|
{t('devices.confirmRevokeTitle')}
|
|
</h3>
|
|
<p className="text-sm text-neutral-600 dark:text-neutral-300 mb-5">
|
|
{t('devices.confirmRevokeBody').replace('{label}', device.label)}
|
|
</p>
|
|
<div className="flex gap-3">
|
|
<Button type="button" variant="secondary" size="md" onClick={onCancel} className="flex-1">
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="primary"
|
|
tone="danger"
|
|
size="md"
|
|
onClick={onConfirm}
|
|
className="flex-1"
|
|
data-testid="confirm-revoke-btn">
|
|
{t('devices.revoke')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main panel
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DevicesPanel = () => {
|
|
const { t } = useT();
|
|
|
|
const [devices, setDevices] = useState<PairedDevice[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [revokeTarget, setRevokeTarget] = useState<PairedDevice | null>(null);
|
|
const [revoking, setRevoking] = useState(false);
|
|
const [showPairModal, setShowPairModal] = useState(false);
|
|
const [toasts, setToasts] = useState<ToastNotification[]>([]);
|
|
|
|
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
|
|
const newToast: ToastNotification = { ...toast, id: `toast-${Date.now()}-${Math.random()}` };
|
|
setToasts(prev => [...prev, newToast]);
|
|
}, []);
|
|
|
|
const removeToast = useCallback((id: string) => {
|
|
setToasts(prev => prev.filter(t => t.id !== id));
|
|
}, []);
|
|
|
|
// Import callCoreRpc lazily via module-level reference to avoid circular deps.
|
|
const loadDevices = useCallback(async () => {
|
|
log('[devices-ui] loadDevices start');
|
|
setError(null);
|
|
try {
|
|
const res = await callCoreRpc<ListDevicesResponse>({
|
|
method: 'openhuman.devices_list',
|
|
params: {},
|
|
});
|
|
const active = res.devices.filter(d => !d.revoked);
|
|
log('[devices-ui] loadDevices got %d device(s)', active.length);
|
|
setDevices(active);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
log('[devices-ui] loadDevices error: %s', msg);
|
|
setError(t('devices.loadFailed').replace('{message}', msg));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [t]);
|
|
|
|
// intervalRef keeps the poll alive when the pair modal is open.
|
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const startPolling = useCallback(() => {
|
|
if (pollRef.current) return;
|
|
pollRef.current = setInterval(() => {
|
|
void loadDevices();
|
|
}, 2_000);
|
|
log('[devices-ui] started 2s poll for device updates');
|
|
}, [loadDevices]);
|
|
|
|
const stopPolling = useCallback(() => {
|
|
if (pollRef.current) {
|
|
clearInterval(pollRef.current);
|
|
pollRef.current = null;
|
|
log('[devices-ui] stopped poll');
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadDevices();
|
|
return stopPolling;
|
|
}, [loadDevices, stopPolling]);
|
|
|
|
const handleOpenPairModal = () => {
|
|
log('[devices-ui] opening pair modal');
|
|
setShowPairModal(true);
|
|
startPolling();
|
|
};
|
|
|
|
const handleClosePairModal = () => {
|
|
log('[devices-ui] closing pair modal');
|
|
setShowPairModal(false);
|
|
stopPolling();
|
|
void loadDevices();
|
|
};
|
|
|
|
const handlePaired = (channelId: string) => {
|
|
log('[devices-ui] DevicePaired event channelId=%s', channelId);
|
|
addToast({
|
|
type: 'success',
|
|
title: t('devices.devicePairedTitle'),
|
|
message: t('devices.devicePairedMessage'),
|
|
});
|
|
stopPolling();
|
|
setShowPairModal(false);
|
|
void loadDevices();
|
|
};
|
|
|
|
const confirmRevoke = async () => {
|
|
if (!revokeTarget) return;
|
|
const target = revokeTarget;
|
|
setRevoking(true);
|
|
log('[devices-ui] revoking channel_id=%s', target.channel_id);
|
|
try {
|
|
await callCoreRpc({
|
|
method: 'openhuman.devices_revoke',
|
|
params: { channel_id: target.channel_id },
|
|
});
|
|
log('[devices-ui] revoke ok channel_id=%s', target.channel_id);
|
|
addToast({
|
|
type: 'success',
|
|
title: t('devices.deviceRevokedTitle'),
|
|
message: t('devices.deviceRevokedMessage').replace('{label}', target.label),
|
|
});
|
|
setRevokeTarget(null);
|
|
await loadDevices();
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
log('[devices-ui] revoke error: %s', msg);
|
|
addToast({ type: 'error', title: t('devices.revokeFailedTitle'), message: msg });
|
|
} finally {
|
|
setRevoking(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<SettingsPanel
|
|
description={t('settings.account.devicesDesc')}
|
|
action={
|
|
<Button type="button" variant="primary" size="xs" onClick={handleOpenPairModal}>
|
|
{t('devices.pairIphone')}
|
|
</Button>
|
|
}>
|
|
<div className="pb-3 flex items-center gap-2">
|
|
{/* Bespoke beta badge — intentional marketing chip */}
|
|
<SettingsBadge variant="warning">{t('devices.betaBadge')}</SettingsBadge>
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('devices.betaText')}</p>
|
|
</div>
|
|
|
|
<div className="pb-5 space-y-3">
|
|
{loading && (
|
|
<div className="flex items-center justify-center py-12">
|
|
<svg className="w-5 h-5 animate-spin text-neutral-400" fill="none" viewBox="0 0 24 24">
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
/>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && error && <SettingsStatusLine saving={false} error={error} savingLabel="" />}
|
|
|
|
{!loading && !error && devices.length === 0 && (
|
|
<SettingsSection>
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<div className="w-12 h-12 rounded-xl bg-primary-50 dark:bg-primary-500/10 flex items-center justify-center mb-3">
|
|
<svg
|
|
className="w-6 h-6 text-primary-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.5}
|
|
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<SettingsEmptyState label={t('devices.noPaired')} />
|
|
<p className="text-xs text-neutral-400 dark:text-neutral-500 mb-4 max-w-xs">
|
|
{t('devices.emptyState')}
|
|
</p>
|
|
<Button type="button" variant="primary" size="sm" onClick={handleOpenPairModal}>
|
|
{t('devices.pairIphone')}
|
|
</Button>
|
|
</div>
|
|
</SettingsSection>
|
|
)}
|
|
|
|
{!loading && !error && devices.length > 0 && (
|
|
<SettingsSection>
|
|
<ul>
|
|
{devices.map(device => (
|
|
<DeviceRow
|
|
key={device.channel_id}
|
|
device={device}
|
|
onRevoke={d => {
|
|
log('[devices-ui] revoke requested channel_id=%s', d.channel_id);
|
|
setRevokeTarget(d);
|
|
}}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</SettingsSection>
|
|
)}
|
|
</div>
|
|
|
|
{revokeTarget && (
|
|
<ConfirmRevokeDialog
|
|
device={revokeTarget}
|
|
onConfirm={() => {
|
|
void confirmRevoke();
|
|
}}
|
|
onCancel={() => {
|
|
if (!revoking) setRevokeTarget(null);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{showPairModal && <PairPhoneModal onClose={handleClosePairModal} onPaired={handlePaired} />}
|
|
|
|
<ToastContainer notifications={toasts} onRemove={removeToast} />
|
|
</SettingsPanel>
|
|
);
|
|
};
|
|
|
|
export default DevicesPanel;
|