mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-28 10:01:31 +02:00
Improve v0.9.7 startup and runtime reliability
Prioritize cached first-paint data, defer heavyweight feed synthesis, make MeshChat activation explicit, improve CCTV media handling, and tighten desktop runtime packaging filters.
This commit is contained in:
@@ -8,8 +8,11 @@ export interface HlsVideoHandle {
|
||||
get paused(): boolean;
|
||||
}
|
||||
|
||||
const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; onError?: () => void }>(
|
||||
({ url, className, onError }, ref) => {
|
||||
const HlsVideo = forwardRef<
|
||||
HlsVideoHandle,
|
||||
{ url: string; className?: string; onError?: () => void; onLoaded?: () => void }
|
||||
>(
|
||||
({ url, className, onError, onLoaded }, ref) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -35,6 +38,7 @@ const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; o
|
||||
hls.on(Hls.Events.ERROR, (_e: unknown, data: { fatal?: boolean }) => {
|
||||
if (data.fatal) onError?.();
|
||||
});
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => onLoaded?.());
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
hlsInstance = hls;
|
||||
@@ -47,7 +51,7 @@ const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; o
|
||||
cancelled = true;
|
||||
hlsInstance?.destroy();
|
||||
};
|
||||
}, [url, onError]);
|
||||
}, [url, onError, onLoaded]);
|
||||
|
||||
return (
|
||||
<video
|
||||
@@ -56,6 +60,9 @@ const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; o
|
||||
muted
|
||||
playsInline
|
||||
onError={() => onError?.()}
|
||||
onCanPlay={() => onLoaded?.()}
|
||||
onLoadedData={() => onLoaded?.()}
|
||||
onPlaying={() => onLoaded?.()}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -5962,6 +5962,7 @@ const MaplibreViewer = ({
|
||||
return (
|
||||
<CctvFullscreenModal
|
||||
url={url}
|
||||
rawUrl={rawUrl}
|
||||
mediaType={mt}
|
||||
isVideo={isVideo}
|
||||
cameraName={cameraName}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { AlertTriangle, Play, Pause } from 'lucide-react';
|
||||
import HlsVideo, { type HlsVideoHandle } from '@/components/HlsVideo';
|
||||
|
||||
export interface CctvFullscreenModalProps {
|
||||
url: string;
|
||||
rawUrl?: string;
|
||||
mediaType: string;
|
||||
isVideo: boolean;
|
||||
cameraName: string;
|
||||
@@ -16,6 +17,7 @@ export interface CctvFullscreenModalProps {
|
||||
|
||||
export function CctvFullscreenModal({
|
||||
url,
|
||||
rawUrl = '',
|
||||
mediaType,
|
||||
isVideo,
|
||||
cameraName,
|
||||
@@ -25,8 +27,60 @@ export function CctvFullscreenModal({
|
||||
}: CctvFullscreenModalProps) {
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [mediaError, setMediaError] = useState(false);
|
||||
const [mediaLoaded, setMediaLoaded] = useState(false);
|
||||
const [sourceIndex, setSourceIndex] = useState(0);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<HlsVideoHandle>(null);
|
||||
const sources = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return [url, rawUrl]
|
||||
.map((candidate) => String(candidate || '').trim())
|
||||
.filter((candidate) => {
|
||||
if (!candidate || seen.has(candidate)) return false;
|
||||
seen.add(candidate);
|
||||
return true;
|
||||
});
|
||||
}, [rawUrl, url]);
|
||||
const activeUrl = sources[sourceIndex] || '';
|
||||
|
||||
useEffect(() => {
|
||||
setSourceIndex(0);
|
||||
setMediaError(false);
|
||||
setMediaLoaded(false);
|
||||
setPaused(false);
|
||||
}, [rawUrl, url]);
|
||||
|
||||
useEffect(() => {
|
||||
setMediaLoaded(false);
|
||||
}, [activeUrl]);
|
||||
|
||||
const handleMediaFailure = useCallback(() => {
|
||||
setSourceIndex((idx) => {
|
||||
const next = idx + 1;
|
||||
if (next < sources.length) {
|
||||
setMediaError(false);
|
||||
return next;
|
||||
}
|
||||
setMediaError(true);
|
||||
return idx;
|
||||
});
|
||||
}, [sources.length]);
|
||||
|
||||
const handleMediaReady = useCallback(() => {
|
||||
setMediaLoaded(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceIndex !== 0 || sources.length < 2 || mediaLoaded || mediaError) return;
|
||||
const timeoutMs = mediaType === 'hls' ? 3200 : 1800;
|
||||
const timer = window.setTimeout(() => {
|
||||
setSourceIndex((idx) => {
|
||||
if (idx !== 0 || mediaLoaded) return idx;
|
||||
return 1;
|
||||
});
|
||||
}, timeoutMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [mediaError, mediaLoaded, mediaType, sourceIndex, sources.length]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (mediaType === 'hls') {
|
||||
@@ -176,17 +230,21 @@ export function CctvFullscreenModal({
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{url ? (
|
||||
{activeUrl ? (
|
||||
<>
|
||||
{mediaType === 'video' && !mediaError && (
|
||||
<video
|
||||
key={activeUrl}
|
||||
ref={videoRef}
|
||||
src={url}
|
||||
src={activeUrl}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
onError={() => setMediaError(true)}
|
||||
onError={handleMediaFailure}
|
||||
onCanPlay={handleMediaReady}
|
||||
onLoadedData={handleMediaReady}
|
||||
onPlaying={handleMediaReady}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 'calc(100vh - 260px)',
|
||||
@@ -197,15 +255,18 @@ export function CctvFullscreenModal({
|
||||
)}
|
||||
{mediaType === 'hls' && !mediaError && (
|
||||
<HlsVideo
|
||||
key={activeUrl}
|
||||
ref={hlsRef}
|
||||
url={url}
|
||||
onError={() => setMediaError(true)}
|
||||
className=""
|
||||
url={activeUrl}
|
||||
onError={handleMediaFailure}
|
||||
onLoaded={handleMediaReady}
|
||||
className="max-w-full max-h-[calc(100vh-260px)] object-contain"
|
||||
/>
|
||||
)}
|
||||
{mediaType === 'mjpeg' && (
|
||||
<img
|
||||
src={url}
|
||||
key={activeUrl}
|
||||
src={activeUrl}
|
||||
alt="MJPEG Feed"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
@@ -213,14 +274,14 @@ export function CctvFullscreenModal({
|
||||
objectFit: 'contain',
|
||||
filter: 'contrast(1.25) saturate(0.5)',
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
onError={handleMediaFailure}
|
||||
onLoad={handleMediaReady}
|
||||
/>
|
||||
)}
|
||||
{(mediaType === 'image' || mediaType === 'satellite') && (
|
||||
<img
|
||||
src={url}
|
||||
key={activeUrl}
|
||||
src={activeUrl}
|
||||
alt="CCTV Feed"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
@@ -228,10 +289,8 @@ export function CctvFullscreenModal({
|
||||
objectFit: 'contain',
|
||||
filter: 'contrast(1.25) saturate(0.5)',
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
onError={handleMediaFailure}
|
||||
onLoad={handleMediaReady}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -239,7 +298,7 @@ export function CctvFullscreenModal({
|
||||
{mediaError && (
|
||||
<div style={{ fontSize: 11, color: 'rgba(239,68,68,0.7)', fontFamily: 'monospace', letterSpacing: '0.15em', textAlign: 'center', padding: 40 }}>
|
||||
FEED UNAVAILABLE<br />
|
||||
<span style={{ fontSize: 9, color: 'rgba(148,163,184,0.5)' }}>stream failed to load — source may be offline</span>
|
||||
<span style={{ fontSize: 9, color: 'rgba(148,163,184,0.5)' }}>proxy and direct source both failed</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -329,10 +388,10 @@ export function CctvFullscreenModal({
|
||||
{cameraName}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
{url && (
|
||||
{activeUrl && (
|
||||
<>
|
||||
<a
|
||||
href={url}
|
||||
href={rawUrl || activeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
@@ -354,7 +413,7 @@ export function CctvFullscreenModal({
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
await navigator.clipboard.writeText(rawUrl || activeUrl);
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
style={{
|
||||
|
||||
@@ -111,6 +111,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
identityWizardStatus,
|
||||
setIdentityWizardStatus,
|
||||
meshQuickStatus,
|
||||
meshSessionActive,
|
||||
publicMeshAddress,
|
||||
meshView,
|
||||
setMeshView,
|
||||
@@ -119,6 +120,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
// Identity
|
||||
identity,
|
||||
publicIdentity,
|
||||
hasStoredPublicLaneIdentity,
|
||||
hasPublicLaneIdentity,
|
||||
hasId,
|
||||
shouldShowIdentityWarning,
|
||||
@@ -255,6 +257,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
openChat,
|
||||
handleCreatePublicIdentity,
|
||||
handleQuickCreatePublicIdentity,
|
||||
handleActivatePublicMeshSession,
|
||||
handleLeaveWormholeForPublicMesh,
|
||||
handleResetPublicIdentity,
|
||||
handleBootstrapPrivateIdentity,
|
||||
@@ -324,6 +327,40 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
}
|
||||
void handleRequestAccess(targetId);
|
||||
};
|
||||
const meshActivationText =
|
||||
meshQuickStatus?.text ||
|
||||
(publicMeshBlockedByWormhole
|
||||
? hasStoredPublicLaneIdentity
|
||||
? 'Wormhole is active. Turning MeshChat on will turn Wormhole off and use your saved public mesh key.'
|
||||
: 'Wormhole is active. Turning MeshChat on will turn Wormhole off and mint a separate public mesh key.'
|
||||
: hasStoredPublicLaneIdentity
|
||||
? 'MeshChat is off. Turn it on to use your saved public mesh key.'
|
||||
: 'Public mesh posting needs a mesh key. One tap gets you a fresh address.');
|
||||
const handleMeshActivationAction = () => {
|
||||
if (hasStoredPublicLaneIdentity) {
|
||||
void handleActivatePublicMeshSession();
|
||||
return;
|
||||
}
|
||||
if (publicMeshBlockedByWormhole) {
|
||||
void handleLeaveWormholeForPublicMesh();
|
||||
return;
|
||||
}
|
||||
void handleQuickCreatePublicIdentity();
|
||||
};
|
||||
const meshActivationLabel = identityWizardBusy
|
||||
? 'GETTING MESH KEY'
|
||||
: hasStoredPublicLaneIdentity
|
||||
? 'TURN ON MESH'
|
||||
: publicMeshBlockedByWormhole
|
||||
? 'TURN OFF WORMHOLE FOR MESH'
|
||||
: 'GET MESH KEY';
|
||||
const meshActivationSideLabel = identityWizardBusy
|
||||
? 'WORKING...'
|
||||
: hasStoredPublicLaneIdentity
|
||||
? 'USE SAVED KEY'
|
||||
: publicMeshBlockedByWormhole
|
||||
? 'AUTO DISABLE'
|
||||
: 'ONE TAP';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1120,16 +1157,25 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-[var(--text-muted)] truncate">
|
||||
{publicMeshAddress ? `ADDR ${publicMeshAddress.toUpperCase()}` : 'NO PUBLIC MESH ADDRESS'}
|
||||
{meshSessionActive && publicMeshAddress
|
||||
? `ADDR ${publicMeshAddress.toUpperCase()}`
|
||||
: publicMeshAddress
|
||||
? 'MESH OFF / KEY SAVED'
|
||||
: 'NO PUBLIC MESH ADDRESS'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar px-3 py-1.5 border-l-2 border-cyan-800/25">
|
||||
{meshView === 'channel' && filteredMeshMessages.length === 0 && (
|
||||
{!meshSessionActive && (
|
||||
<div className="text-[12px] font-mono text-green-300/70 text-center py-4 leading-[1.65]">
|
||||
MeshChat is off. Turn it on to connect the public mesh lane.
|
||||
</div>
|
||||
)}
|
||||
{meshSessionActive && meshView === 'channel' && filteredMeshMessages.length === 0 && (
|
||||
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
|
||||
No messages from {meshRegion} / {meshChannel}
|
||||
</div>
|
||||
)}
|
||||
{meshView === 'inbox' && (
|
||||
{meshSessionActive && meshView === 'inbox' && (
|
||||
<>
|
||||
{!publicMeshAddress && (
|
||||
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
|
||||
@@ -2049,7 +2095,9 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
? meshDirectTarget
|
||||
? `→ MESH / TO ${meshDirectTarget.toUpperCase()}`
|
||||
: `→ MESH / ${meshRegion} / ${meshChannel}`
|
||||
: '→ MESH LOCKED'
|
||||
: hasStoredPublicLaneIdentity
|
||||
? '→ MESH OFF'
|
||||
: '→ MESH LOCKED'
|
||||
: activeTab === 'dms' && secureDmBlocked
|
||||
? '→ DEAD DROP LOCKED'
|
||||
: dmView === 'chat' && selectedContact
|
||||
@@ -2068,10 +2116,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
: 'text-green-300/70'
|
||||
}`}
|
||||
>
|
||||
{meshQuickStatus?.text ||
|
||||
(publicMeshBlockedByWormhole
|
||||
? 'Wormhole is active. Turn it off here and we will mint a separate public mesh key for you.'
|
||||
: 'Public mesh posting needs a mesh key. One tap gets you a fresh address.')}
|
||||
{meshActivationText}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-3 pb-2 pt-1">
|
||||
@@ -2103,30 +2148,16 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
</button>
|
||||
) : activeTab === 'meshtastic' && !hasPublicLaneIdentity ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (publicMeshBlockedByWormhole) {
|
||||
void handleLeaveWormholeForPublicMesh();
|
||||
return;
|
||||
}
|
||||
void handleQuickCreatePublicIdentity();
|
||||
}}
|
||||
onClick={handleMeshActivationAction}
|
||||
disabled={identityWizardBusy}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-green-700/40 bg-green-950/15 text-green-300 hover:bg-green-950/25 hover:border-green-500/50 transition-colors"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 text-sm font-mono tracking-[0.2em]">
|
||||
<Radio size={11} />
|
||||
{identityWizardBusy
|
||||
? 'GETTING MESH KEY'
|
||||
: publicMeshBlockedByWormhole
|
||||
? 'TURN OFF WORMHOLE FOR MESH'
|
||||
: 'GET MESH KEY'}
|
||||
{meshActivationLabel}
|
||||
</span>
|
||||
<span className="text-[12px] font-mono text-green-300/70">
|
||||
{identityWizardBusy
|
||||
? 'WORKING...'
|
||||
: publicMeshBlockedByWormhole
|
||||
? 'AUTO FIX'
|
||||
: 'ONE TAP'}
|
||||
{meshActivationSideLabel}
|
||||
</span>
|
||||
</button>
|
||||
) : activeTab === 'meshtastic' && meshDirectTarget ? (
|
||||
@@ -2375,8 +2406,8 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
CURRENT STATE
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-1 text-[13px] font-mono text-[var(--text-secondary)] leading-[1.5]">
|
||||
<div>Public mesh key: {hasPublicLaneIdentity ? 'active' : 'not issued'}</div>
|
||||
<div>Public mesh address: {hasPublicLaneIdentity && publicMeshAddress ? publicMeshAddress.toUpperCase() : 'not ready'}</div>
|
||||
<div>Public mesh key: {hasPublicLaneIdentity ? 'active' : hasStoredPublicLaneIdentity ? 'saved / off' : 'not issued'}</div>
|
||||
<div>Public mesh address: {publicMeshAddress ? publicMeshAddress.toUpperCase() : 'not ready'}</div>
|
||||
<div>Wormhole lane: {wormholeEnabled && wormholeReadyState ? 'active' : wormholeEnabled ? 'starting' : 'off'}</div>
|
||||
<div>Wormhole descriptor: {wormholeDescriptor?.nodeId || 'not cached yet'}</div>
|
||||
</div>
|
||||
@@ -2385,6 +2416,10 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (hasStoredPublicLaneIdentity) {
|
||||
void handleActivatePublicMeshSession();
|
||||
return;
|
||||
}
|
||||
if (publicMeshBlockedByWormhole) {
|
||||
void handleLeaveWormholeForPublicMesh();
|
||||
return;
|
||||
@@ -2396,12 +2431,16 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
>
|
||||
{hasPublicLaneIdentity
|
||||
? 'MESH KEY ACTIVE'
|
||||
: hasStoredPublicLaneIdentity
|
||||
? 'TURN ON MESH'
|
||||
: publicMeshBlockedByWormhole
|
||||
? 'TURN OFF WORMHOLE FOR MESH'
|
||||
: 'GET MESH KEY'}
|
||||
<div className="mt-1 text-[13px] text-green-200/70 normal-case tracking-normal leading-[1.45]">
|
||||
{hasPublicLaneIdentity
|
||||
? 'Your public mesh key is already live for posting.'
|
||||
: hasStoredPublicLaneIdentity
|
||||
? 'Use your saved public mesh key. This turns Wormhole off first if it is active.'
|
||||
: publicMeshBlockedByWormhole
|
||||
? 'One tap turns Wormhole off and mints a separate public mesh key.'
|
||||
: 'One tap for a working mesh key and address.'}
|
||||
|
||||
@@ -313,6 +313,7 @@ export function useMeshChatController({
|
||||
const [identityWizardBusy, setIdentityWizardBusy] = useState(false);
|
||||
const [identityWizardStatus, setIdentityWizardStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [meshQuickStatus, setMeshQuickStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [meshSessionActive, setMeshSessionActive] = useState(false);
|
||||
const [publicMeshAddress, setPublicMeshAddress] = useState('');
|
||||
const [meshView, setMeshView] = useState<'channel' | 'inbox'>('channel');
|
||||
const [meshDirectTarget, setMeshDirectTarget] = useState('');
|
||||
@@ -328,12 +329,14 @@ export function useMeshChatController({
|
||||
const [recentPrivateFallbackReason, setRecentPrivateFallbackReason] = useState('');
|
||||
const [unresolvedSenderSealCount, setUnresolvedSenderSealCount] = useState(0);
|
||||
const [privacyProfile, setPrivacyProfile] = useState<'default' | 'high'>('default');
|
||||
const publicIdentity = clientHydrated ? getNodeIdentity() : null;
|
||||
const hasPublicLaneIdentity = clientHydrated && Boolean(publicIdentity) && hasSovereignty();
|
||||
const storedPublicIdentity = clientHydrated ? getNodeIdentity() : null;
|
||||
const hasStoredPublicLaneIdentity = clientHydrated && Boolean(storedPublicIdentity) && hasSovereignty();
|
||||
const publicIdentity = meshSessionActive ? storedPublicIdentity : null;
|
||||
const hasPublicLaneIdentity = meshSessionActive && hasStoredPublicLaneIdentity;
|
||||
const hasId = Boolean(identity) && (hasSovereignty() || wormholeEnabled);
|
||||
const shouldShowIdentityWarning = activeTab !== 'meshtastic' && !hasId;
|
||||
const privateInfonetReady = wormholeEnabled && wormholeReadyState;
|
||||
const publicMeshBlockedByWormhole = wormholeEnabled && wormholeReadyState && !hasPublicLaneIdentity;
|
||||
const publicMeshBlockedByWormhole = wormholeEnabled || wormholeReadyState;
|
||||
const dmSendQueue = useRef<(() => Promise<void>)[]>([]);
|
||||
const dmSendTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const streamEnabledForSelectedGateRef = useRef(false);
|
||||
@@ -365,6 +368,13 @@ export function useMeshChatController({
|
||||
setClientHydrated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!clientHydrated) return;
|
||||
setMeshSessionActive(false);
|
||||
setMeshMessages([]);
|
||||
setMeshQuickStatus(null);
|
||||
}, [clientHydrated]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeGateSessionStreamStatus((nextStatus) => {
|
||||
@@ -450,6 +460,8 @@ export function useMeshChatController({
|
||||
setSecureModeCached(enabled);
|
||||
setWormholeEnabled(enabled);
|
||||
if (enabled) {
|
||||
setMeshSessionActive(false);
|
||||
setMeshMessages([]);
|
||||
purgeBrowserContactGraph();
|
||||
void hydrateWormholeContacts();
|
||||
}
|
||||
@@ -515,7 +527,7 @@ export function useMeshChatController({
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const senderId = publicIdentity?.nodeId || '';
|
||||
const senderId = storedPublicIdentity?.nodeId || '';
|
||||
if (!senderId || !globalThis.crypto?.subtle) {
|
||||
setPublicMeshAddress('');
|
||||
return;
|
||||
@@ -530,7 +542,7 @@ export function useMeshChatController({
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [publicIdentity?.nodeId]);
|
||||
}, [storedPublicIdentity?.nodeId]);
|
||||
|
||||
const flushDmQueue = useCallback(async () => {
|
||||
const queue = dmSendQueue.current.splice(0);
|
||||
@@ -1138,10 +1150,10 @@ export function useMeshChatController({
|
||||
[meshMessages, mutedUsers],
|
||||
);
|
||||
const meshInboxMessages = useMemo(() => {
|
||||
if (!publicMeshAddress) return [];
|
||||
if (!meshSessionActive || !publicMeshAddress) return [];
|
||||
const target = publicMeshAddress.toLowerCase();
|
||||
return filteredMeshMessages.filter((m) => String(m.to || '').toLowerCase() === target);
|
||||
}, [filteredMeshMessages, publicMeshAddress]);
|
||||
}, [filteredMeshMessages, meshSessionActive, publicMeshAddress]);
|
||||
|
||||
// ─── InfoNet Polling ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1735,7 +1747,7 @@ export function useMeshChatController({
|
||||
|
||||
// ─── Meshtastic Channel Discovery ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!expanded || activeTab !== 'meshtastic') return;
|
||||
if (!expanded || activeTab !== 'meshtastic' || !meshSessionActive) return;
|
||||
let cancelled = false;
|
||||
const fetchChannels = async () => {
|
||||
try {
|
||||
@@ -1794,12 +1806,12 @@ export function useMeshChatController({
|
||||
cancelled = true;
|
||||
clearInterval(iv);
|
||||
};
|
||||
}, [expanded, activeTab, meshRegion]);
|
||||
}, [expanded, activeTab, meshRegion, meshSessionActive]);
|
||||
|
||||
// ─── Meshtastic Polling ──────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded || activeTab !== 'meshtastic') return;
|
||||
if (!expanded || activeTab !== 'meshtastic' || !meshSessionActive) return;
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
@@ -1823,7 +1835,13 @@ export function useMeshChatController({
|
||||
cancelled = true;
|
||||
clearInterval(iv);
|
||||
};
|
||||
}, [expanded, activeTab, meshRegion, meshChannel, meshView]);
|
||||
}, [expanded, activeTab, meshRegion, meshChannel, meshView, meshSessionActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (meshSessionActive) return;
|
||||
setMeshMessages([]);
|
||||
setMeshQuickStatus(null);
|
||||
}, [meshSessionActive]);
|
||||
|
||||
// ─── DM Polling ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2305,7 +2323,8 @@ export function useMeshChatController({
|
||||
|
||||
const handleSend = async () => {
|
||||
const msg = inputValue.trim();
|
||||
if (!msg || !hasId || busy) return;
|
||||
if (!msg || busy) return;
|
||||
if (activeTab !== 'meshtastic' && !hasId) return;
|
||||
|
||||
const cooldownMs = activeTab === 'dms' ? 0 : 30_000;
|
||||
const now = Date.now();
|
||||
@@ -2392,13 +2411,15 @@ export function useMeshChatController({
|
||||
]);
|
||||
setGateReplyContext(null);
|
||||
} else if (activeTab === 'meshtastic') {
|
||||
if (!publicIdentity || !hasSovereignty()) {
|
||||
if (!meshSessionActive || !publicIdentity || !hasSovereignty()) {
|
||||
setInputValue(msg);
|
||||
setLastSendTime(0);
|
||||
setSendError('public mesh identity needed');
|
||||
setSendError(meshSessionActive ? 'public mesh identity needed' : 'meshchat is off');
|
||||
openIdentityWizard({
|
||||
type: 'err',
|
||||
text: 'Quick fix: create a public mesh identity below, then retry your send.',
|
||||
text: hasStoredPublicLaneIdentity
|
||||
? 'Quick fix: turn MeshChat on below, then retry your send.'
|
||||
: 'Quick fix: create a public mesh identity below, then retry your send.',
|
||||
});
|
||||
setTimeout(() => setSendError(''), 4000);
|
||||
setBusy(false);
|
||||
@@ -3959,16 +3980,36 @@ export function useMeshChatController({
|
||||
[inputDisabled],
|
||||
);
|
||||
|
||||
const disableWormholeForPublicMesh = useCallback(async () => {
|
||||
const requireBackendLeave = wormholeEnabled || wormholeReadyState;
|
||||
try {
|
||||
await leaveWormhole();
|
||||
} catch (err) {
|
||||
if (requireBackendLeave) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
setWormholeEnabled(false);
|
||||
setWormholeReadyState(false);
|
||||
setWormholeRnsReady(false);
|
||||
setWormholeRnsDirectReady(false);
|
||||
setWormholeRnsPeers({ active: 0, configured: 0 });
|
||||
setSecureModeCached(false);
|
||||
}, [wormholeEnabled, wormholeReadyState]);
|
||||
|
||||
const createPublicMeshIdentity = useCallback(
|
||||
async ({ closeWizardOnSuccess }: { closeWizardOnSuccess: boolean }) => {
|
||||
setIdentityWizardBusy(true);
|
||||
setIdentityWizardStatus(null);
|
||||
try {
|
||||
await disableWormholeForPublicMesh();
|
||||
const nextIdentity = await generateNodeKeys();
|
||||
const nextAddress = await derivePublicMeshAddress(nextIdentity.nodeId).catch(() => '');
|
||||
const readyAddress = (nextAddress || nextIdentity.nodeId).toUpperCase();
|
||||
setIdentity(nextIdentity);
|
||||
setPublicMeshAddress(nextAddress || nextIdentity.nodeId);
|
||||
setMeshSessionActive(true);
|
||||
setMeshMessages([]);
|
||||
setSendError('');
|
||||
const successText = `Mesh key ready. Address ${readyAddress} is live for this testnet session.`;
|
||||
setIdentityWizardStatus({
|
||||
@@ -3997,7 +4038,7 @@ export function useMeshChatController({
|
||||
setIdentityWizardBusy(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[disableWormholeForPublicMesh],
|
||||
);
|
||||
|
||||
const handleCreatePublicIdentity = useCallback(async () => {
|
||||
@@ -4013,6 +4054,45 @@ export function useMeshChatController({
|
||||
}
|
||||
}, [createPublicMeshIdentity]);
|
||||
|
||||
const handleActivatePublicMeshSession = useCallback(async () => {
|
||||
setIdentityWizardBusy(true);
|
||||
setIdentityWizardStatus(null);
|
||||
setMeshQuickStatus(null);
|
||||
try {
|
||||
const savedIdentity = getNodeIdentity();
|
||||
if (!savedIdentity || !hasSovereignty()) {
|
||||
const text = 'No saved public mesh key is available. Create a mesh key first.';
|
||||
setMeshSessionActive(false);
|
||||
setIdentityWizardStatus({ type: 'err', text });
|
||||
setMeshQuickStatus({ type: 'err', text });
|
||||
return { ok: false as const, text };
|
||||
}
|
||||
await disableWormholeForPublicMesh();
|
||||
const nextAddress = await derivePublicMeshAddress(savedIdentity.nodeId).catch(() => '');
|
||||
const readyAddress = (nextAddress || savedIdentity.nodeId).toUpperCase();
|
||||
setIdentity(savedIdentity);
|
||||
setPublicMeshAddress(nextAddress || savedIdentity.nodeId);
|
||||
setMeshSessionActive(true);
|
||||
setMeshMessages([]);
|
||||
setSendError('');
|
||||
const text = `MeshChat is on with saved address ${readyAddress}.`;
|
||||
setIdentityWizardStatus({ type: 'ok', text });
|
||||
setMeshQuickStatus({ type: 'ok', text });
|
||||
return { ok: true as const, text };
|
||||
} catch (err) {
|
||||
const message =
|
||||
typeof err === 'object' && err !== null && 'message' in err
|
||||
? String((err as { message?: string }).message)
|
||||
: 'unknown error';
|
||||
const text = `Could not turn MeshChat on: ${message}`;
|
||||
setIdentityWizardStatus({ type: 'err', text });
|
||||
setMeshQuickStatus({ type: 'err', text });
|
||||
return { ok: false as const, text };
|
||||
} finally {
|
||||
setIdentityWizardBusy(false);
|
||||
}
|
||||
}, [disableWormholeForPublicMesh]);
|
||||
|
||||
const handleReplyToMeshAddress = useCallback((address: string) => {
|
||||
const target = String(address || '').trim();
|
||||
if (!target) return;
|
||||
@@ -4023,36 +4103,16 @@ export function useMeshChatController({
|
||||
}, []);
|
||||
|
||||
const handleLeaveWormholeForPublicMesh = useCallback(async () => {
|
||||
setIdentityWizardBusy(true);
|
||||
setIdentityWizardStatus(null);
|
||||
setMeshQuickStatus(null);
|
||||
try {
|
||||
await leaveWormhole();
|
||||
setWormholeEnabled(false);
|
||||
setWormholeReadyState(false);
|
||||
setWormholeRnsReady(false);
|
||||
setWormholeRnsDirectReady(false);
|
||||
setWormholeRnsPeers({ active: 0, configured: 0 });
|
||||
setSecureModeCached(false);
|
||||
const result = await createPublicMeshIdentity({ closeWizardOnSuccess: false });
|
||||
const status = { type: result.ok ? 'ok' as const : 'err' as const, text: result.text };
|
||||
setIdentityWizardStatus(status);
|
||||
setMeshQuickStatus(status);
|
||||
if (result.ok) {
|
||||
window.setTimeout(() => setIdentityWizardOpen(false), 900);
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
typeof err === 'object' && err !== null && 'message' in err
|
||||
? String((err as { message?: string }).message)
|
||||
: 'unknown error';
|
||||
const text = `Could not turn Wormhole off for public mesh: ${message}`;
|
||||
setIdentityWizardStatus({ type: 'err', text });
|
||||
setMeshQuickStatus({ type: 'err', text });
|
||||
} finally {
|
||||
setIdentityWizardBusy(false);
|
||||
const result = hasStoredPublicLaneIdentity
|
||||
? await handleActivatePublicMeshSession()
|
||||
: await createPublicMeshIdentity({ closeWizardOnSuccess: false });
|
||||
const status = { type: result.ok ? 'ok' as const : 'err' as const, text: result.text };
|
||||
setIdentityWizardStatus(status);
|
||||
setMeshQuickStatus(status);
|
||||
if (result.ok) {
|
||||
window.setTimeout(() => setIdentityWizardOpen(false), 900);
|
||||
}
|
||||
}, [createPublicMeshIdentity]);
|
||||
}, [createPublicMeshIdentity, handleActivatePublicMeshSession, hasStoredPublicLaneIdentity]);
|
||||
|
||||
const handleResetPublicIdentity = useCallback(async () => {
|
||||
if (wormholeEnabled && wormholeReadyState) {
|
||||
@@ -4065,8 +4125,11 @@ export function useMeshChatController({
|
||||
setIdentityWizardBusy(true);
|
||||
setIdentityWizardStatus(null);
|
||||
try {
|
||||
setMeshSessionActive(false);
|
||||
setMeshMessages([]);
|
||||
await clearBrowserIdentityState();
|
||||
setIdentity(null);
|
||||
setPublicMeshAddress('');
|
||||
setContacts({});
|
||||
setSelectedContact('');
|
||||
setDmMessages([]);
|
||||
@@ -4091,6 +4154,8 @@ export function useMeshChatController({
|
||||
}, [wormholeEnabled, wormholeReadyState]);
|
||||
|
||||
const handleBootstrapPrivateIdentity = useCallback(async () => {
|
||||
setMeshSessionActive(false);
|
||||
setMeshMessages([]);
|
||||
if (wormholeEnabled && wormholeReadyState) {
|
||||
setIdentityWizardStatus({
|
||||
type: 'ok',
|
||||
@@ -4175,6 +4240,7 @@ export function useMeshChatController({
|
||||
identityWizardStatus,
|
||||
setIdentityWizardStatus,
|
||||
meshQuickStatus,
|
||||
meshSessionActive,
|
||||
publicMeshAddress,
|
||||
meshView,
|
||||
setMeshView,
|
||||
@@ -4183,6 +4249,7 @@ export function useMeshChatController({
|
||||
// Identity
|
||||
identity,
|
||||
publicIdentity,
|
||||
hasStoredPublicLaneIdentity,
|
||||
hasPublicLaneIdentity,
|
||||
hasId,
|
||||
shouldShowIdentityWarning,
|
||||
@@ -4320,6 +4387,7 @@ export function useMeshChatController({
|
||||
openChat,
|
||||
handleCreatePublicIdentity,
|
||||
handleQuickCreatePublicIdentity,
|
||||
handleActivatePublicMeshSession,
|
||||
handleLeaveWormholeForPublicMesh,
|
||||
handleResetPublicIdentity,
|
||||
handleBootstrapPrivateIdentity,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Database, Clock, X } from 'lucide-react';
|
||||
|
||||
const CURRENT_VERSION = '0.9.7';
|
||||
const STORAGE_KEY = `shadowbroker_startup_warmup_notice_v${CURRENT_VERSION}`;
|
||||
|
||||
interface StartupWarmupModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function StartupWarmupModal({ onClose }: StartupWarmupModalProps) {
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="warmup-backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[10000]"
|
||||
onClick={handleDismiss}
|
||||
/>
|
||||
<motion.div
|
||||
key="warmup-modal"
|
||||
initial={{ opacity: 0, scale: 0.92, y: 18 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.92, y: 18 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed inset-0 z-[10001] flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<div
|
||||
className="w-[520px] max-w-[calc(100vw-32px)] bg-[var(--bg-secondary)]/98 border border-cyan-900/50 pointer-events-auto overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-5 border-b border-[var(--border-primary)]/80 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
|
||||
<Database size={18} className="text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">
|
||||
STARTUP CACHE
|
||||
</h2>
|
||||
<span className="text-[13px] text-[var(--text-muted)] font-mono tracking-widest">
|
||||
FIRST RUN WARMUP
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="w-8 h-8 border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="bg-cyan-950/20 border border-cyan-500/20 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock size={15} className="text-cyan-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] text-cyan-300 font-mono font-bold tracking-widest">
|
||||
MASS DATA SYNTHESIS
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
The first launch builds local caches for flights, ships, satellites, CCTV, fires,
|
||||
and threat intelligence. Cached launches paint the map much faster; a brand-new
|
||||
install can take a few minutes while upstream feeds are synthesized.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="w-full py-3 border border-cyan-500/40 text-cyan-300 hover:text-cyan-100 hover:border-cyan-400/70 hover:bg-cyan-950/30 transition-all font-mono text-[12px] tracking-[0.18em] font-bold"
|
||||
>
|
||||
CONTINUE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export function useStartupWarmupNotice() {
|
||||
const [showWarmupNotice, setShowWarmupNotice] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setShowWarmupNotice(localStorage.getItem(STORAGE_KEY) !== 'true');
|
||||
} catch {
|
||||
setShowWarmupNotice(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { showWarmupNotice, setShowWarmupNotice };
|
||||
}
|
||||
Reference in New Issue
Block a user