SkateSpots/src/components/OfflineIndicator.tsx
SkateSpots 65d824d952 feat(offline): actually wire up the offline write queue
lib/offlineCache already had a pendingPosts table, a sync hook and an
OfflineIndicator badge, but nothing ever called queuePost - so an offline action
just failed. Now it queues.

- useNostrPublish: if isOffline() (no 15s publish timeout), or every relay
  fails, queue the ALREADY-SIGNED event and resolve. Signing happened at the
  moment of the action, so Amber is never re-prompted at sync time.
- queueSignedEvent: supersedes older queued replaceable (0/3/10000+) and
  addressable (30000+) events, prunes stale ephemeral kinds (2259/24899, 15 min,
  a late 'I'm here' is worse than none) and caps at 100.
- useSyncPendingPosts: publishes the stored signature directly (never through
  useNostrPublish, which would re-queue and duplicate), re-signs only legacy
  unsigned entries, retries every 30s, and returns failed rows so one failure
  can't strand an action.
- A module-level lock keeps ONE publisher even though OfflineIndicator is
  mounted twice (mobile + desktop headers).
- Indicator copy now says 'queued actions' and lists what works offline.
2026-09-29 21:42:50 +07:00

115 lines
4.3 KiB
TypeScript

import { useOnlineStatus, usePendingPostsCount, useSyncPendingPosts } from '@/hooks/useOfflineCache';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Wifi, WifiOff, Cloud, CloudOff, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface OfflineIndicatorProps {
className?: string;
showWhenOnline?: boolean;
}
export function OfflineIndicator({ className, showWhenOnline = false }: OfflineIndicatorProps) {
const isOnline = useOnlineStatus();
const { data: pendingCount } = usePendingPostsCount();
const { isSyncing } = useSyncPendingPosts();
// Don't show when online (unless explicitly requested or there are pending posts)
if (isOnline && !showWhenOnline && !pendingCount && !isSyncing) {
return null;
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'gap-2 h-8 px-3 font-medium',
isOnline
? 'text-green-400 hover:text-green-300 hover:bg-green-500/10'
: 'text-orange-400 hover:text-orange-300 hover:bg-orange-500/10 animate-pulse',
className
)}
>
{isSyncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isOnline ? (
<Wifi className="h-4 w-4" />
) : (
<WifiOff className="h-4 w-4" />
)}
{!isOnline && <span className="text-xs">Offline</span>}
{pendingCount !== undefined && pendingCount > 0 && (
<Badge variant="secondary" className="h-5 px-1.5 text-xs bg-orange-500/20 text-orange-400">
{pendingCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 bg-black border-gray-700">
<div className="space-y-4">
{/* Connection Status */}
<div className="flex items-center gap-3">
<div className={cn(
'w-10 h-10 rounded-full flex items-center justify-center',
isOnline ? 'bg-green-500/20' : 'bg-orange-500/20'
)}>
{isOnline ? (
<Cloud className="h-5 w-5 text-green-400" />
) : (
<CloudOff className="h-5 w-5 text-orange-400" />
)}
</div>
<div>
<p className="font-bold text-white">
{isOnline ? 'Connected' : 'Offline Mode'}
</p>
<p className="text-xs text-gray-400">
{isOnline
? 'All features available'
: 'Browsing cached spots'}
</p>
</div>
</div>
{/* Pending Posts */}
{pendingCount !== undefined && pendingCount > 0 && (
<div className="p-3 rounded-lg bg-orange-500/10 border border-orange-500/30">
<div className="flex items-center justify-between">
<span className="text-sm text-orange-400 font-medium">
{pendingCount} queued action{pendingCount > 1 ? 's' : ''}
</span>
{isSyncing && (
<Badge className="bg-orange-500 text-white text-xs">
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
Syncing
</Badge>
)}
</div>
<p className="text-xs text-gray-400 mt-1">
{isOnline
? 'Publishing when connection stabilizes...'
: 'Will be published when back online'}
</p>
</div>
)}
{/* Offline Features Info */}
{!isOnline && (
<div className="text-xs text-gray-400 space-y-1">
<p className="font-medium text-gray-300">Available offline:</p>
<ul className="list-disc list-inside space-y-0.5">
<li>Browse previously viewed spots</li>
<li>View cached spot details</li>
<li>Keep acting — posts, RSVPs, listings and messages are queued and send when you reconnect</li>
</ul>
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}