SkateSpots/src/components/CrewChat.tsx
SkateSpots 721a5d4b67 chore(lint): clear all react-hooks/exhaustive-deps warnings
Triage per file (not a blanket sweep):
- Memoise shared getters with useCallback and depend on them:
  EventsPage/LabPage/MapPage getTrustScore+getCommentCount (+MapPage getZapTotal)
- Memoise derived values that were recreated each render: CrewChat 'messages',
  RiderCardPage 'cardData'
- Justified eslint-disable for intentional mount-only effects with a comment:
  AppProvider (seed defaults/theme), SpotMap (Leaflet init once),
  SpamFeePayment (auto-pay keyed to invoice/state, not the recreated handler)
- EventCountdown: keep the intentional 'tick' recompute dep (documented disable)

exhaustive-deps: 14 -> 0. Warnings 60 -> 46 (39 unused-vars, 7 react-refresh).
Gate: 101 tests, 0 errors, boot smoke green. AGENTS.md baseline updated.
2026-09-26 22:09:43 +07:00

456 lines
17 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { formatDistanceToNow } from 'date-fns';
import { Lock, Send, Loader2, ShieldCheck, MessageSquare, EyeOff } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrLogin } from '@nostrify/react/login';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import { AmberApprovalHint } from '@/components/AmberApprovalHint';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import {
useGroupConversation,
useSendDirectMessage,
type DirectMessage,
} from '@/hooks/useDirectMessages';
import { useCordnChat } from '@/hooks/useCordnChat';
import { useCrewChatRead } from '@/hooks/useCrewChatRead';
import { resolveCordnEndpoint } from '@/lib/cordn/endpoint';
import type { CordnMessage } from '@/lib/cordn/types';
import { genUserName } from '@/lib/genUserName';
import { useCrewModeration, useHideCrewPost } from '@/hooks/useCrewModeration';
import { cn } from '@/lib/utils';
import { Server } from 'lucide-react';
import type { SkateCrew } from '@/lib/types';
interface CrewChatProps {
crewName: string;
/** The crew founder pubkey. */
founderPubkey: string;
/** All listed member pubkeys (hex). */
members: string[];
/** Delegated admin pubkeys (subset of members). */
admins?: string[];
/** Crew addressable coordinate `kind:pubkey:d` (for moderation labels). */
crewCoord?: string;
/** Whether the viewer can moderate (founder or admin). */
canModerate?: boolean;
/**
* Full crew — when present AND it has a `coordinator` configured, chat runs
* over the Cordn coordinator (encrypted, coordinator-routed). Otherwise it
* falls back to the built-in NIP-17 group DM.
*/
crew?: SkateCrew;
}
/** Normalise a Cordn message into the shared DirectMessage bubble shape. */
function cordnToDirect(m: CordnMessage, groupId: string): DirectMessage {
return {
id: m.id,
pubkey: m.pubkey,
content: m.content,
createdAt: m.createdAt,
conversationWith: groupId,
};
}
/** One message bubble, resolving the author's profile. */
function CrewMessageBubble({
message,
isOwn,
isHidden,
canModerate,
onHide,
}: {
message: DirectMessage;
isOwn: boolean;
isHidden?: boolean;
canModerate?: boolean;
onHide?: (message: DirectMessage) => void;
}) {
const author = useAuthor(message.pubkey);
const meta = author.data?.metadata;
const name = meta?.name ?? genUserName(message.pubkey);
const npub = useMemo(() => nip19.npubEncode(message.pubkey), [message.pubkey]);
const timeAgo = formatDistanceToNow(new Date(message.createdAt * 1000), { addSuffix: true });
return (
<div className={cn('group flex gap-2.5', isOwn && 'flex-row-reverse', isHidden && 'opacity-50')}>
<Link to={`/${npub}`} className="shrink-0">
<Avatar className="h-8 w-8">
<AvatarImage src={meta?.picture} alt={name} />
<AvatarFallback className="text-xs bg-teal-900/40 text-teal-300">
{name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
<div className={cn('flex max-w-[78%] flex-col gap-0.5', isOwn && 'items-end')}>
<div className={cn('flex items-baseline gap-2', isOwn && 'flex-row-reverse')}>
<Link to={`/${npub}`} className="text-xs font-semibold hover:underline truncate max-w-[10rem]">
{name}
</Link>
<span className="text-[10px] text-muted-foreground shrink-0">{timeAgo}</span>
{isHidden && (
<span className="flex items-center gap-0.5 text-[10px] font-bold uppercase text-red-400">
<EyeOff className="h-2.5 w-2.5" /> Hidden
</span>
)}
{canModerate && !isHidden && onHide && (
<button
type="button"
onClick={() => onHide(message)}
className="text-muted-foreground opacity-0 transition-opacity hover:text-red-400 group-hover:opacity-100"
aria-label="Hide this message"
title="Hide message (admin)"
>
<EyeOff className="h-3 w-3" />
</button>
)}
</div>
<div
className={cn(
'rounded-2xl px-3 py-2 text-sm break-words whitespace-pre-wrap',
isOwn ? 'bg-teal-500 text-black rounded-tr-sm' : 'bg-muted rounded-tl-sm',
)}
>
{message.content}
</div>
</div>
</div>
);
}
export function CrewChat({
crewName,
founderPubkey,
members,
admins = [],
crewCoord = '',
canModerate = false,
crew,
}: CrewChatProps) {
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const { toast } = useToast();
const [draft, setDraft] = useState('');
const scrollRef = useRef<HTMLDivElement>(null);
// Cordn transport when the crew advertises a coordinator; else NIP-17.
const cordnEndpoint = useMemo(
() => (crew ? resolveCordnEndpoint(crew) : null),
[crew],
);
const usingCordn = !!cordnEndpoint;
// Moderation — founder + admins can hide chat messages (client-side, since
// NIP-17 messages are E2EE and can't be deleted from relays).
const moderators = useMemo(
() => Array.from(new Set([founderPubkey, ...admins])),
[founderPubkey, admins],
);
const { data: hiddenIds } = useCrewModeration({
crewCoord,
moderators,
enabled: !!crewCoord && moderators.length > 0,
});
const { mutateAsync: hideMessage } = useHideCrewPost(crewCoord);
const handleHideMessage = async (m: DirectMessage) => {
try {
await hideMessage({ postId: m.id, postAuthor: m.pubkey });
toast({ title: 'Message hidden', description: 'Hidden from the crew chat for everyone.' });
} catch (err) {
toast({
title: 'Failed to hide message',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
};
// The chat participants = founder + all listed members (deduped).
const participants = useMemo(
() => Array.from(new Set([founderPubkey, ...members])),
[founderPubkey, members],
);
// Only crew members (founder or a listed member) can read/write the chat,
// because group messages are end-to-end encrypted to the participants.
const isMember = !!user && participants.includes(user.pubkey);
// ── NIP-17 fallback transport ──
const { data: nip17Messages, isLoading: nip17Loading } = useGroupConversation(
!usingCordn && isMember ? participants : [],
);
const { mutateAsync: sendNip17, isPending: nip17Sending } = useSendDirectMessage();
// ── Cordn coordinator transport ──
const cordn = useCordnChat({
endpoint: cordnEndpoint,
roster: participants,
enabled: isMember,
});
// Unify whichever transport is active into one message list + send + state.
// Memoised so its identity is stable for the useMemo/useEffect deps below.
const messages: DirectMessage[] = useMemo(
() =>
usingCordn
? cordn.messages.map((m) => cordnToDirect(m, cordnEndpoint!.groupId))
: (nip17Messages ?? []),
[usingCordn, cordn.messages, cordnEndpoint, nip17Messages],
);
const isLoading = usingCordn ? cordn.isLoading : nip17Loading;
const isPending = usingCordn ? cordn.isSending : nip17Sending;
// ── "New messages" divider ──
// Snapshot the last-seen time ONCE per open so the divider stays put while the
// member reads, instead of jumping as fresh messages stream in. Anything after
// that timestamp (and not authored by me) is "new since your last visit".
const { lastSeen, markSeen } = useCrewChatRead(crewCoord);
const seenAtOpenRef = useRef<number | null>(null);
if (seenAtOpenRef.current === null && lastSeen !== undefined) {
seenAtOpenRef.current = lastSeen;
}
const seenAtOpen = seenAtOpenRef.current ?? 0;
// The first message id that falls after the snapshot — the divider sits above
// it. Skip when there's nothing older (a brand-new reader sees no divider).
const firstUnreadId = useMemo(() => {
if (!user || seenAtOpen <= 0) return null;
const hasOlder = messages.some((m) => m.createdAt <= seenAtOpen);
if (!hasOlder) return null;
const firstNew = messages.find(
(m) => m.createdAt > seenAtOpen && m.pubkey !== user.pubkey,
);
return firstNew?.id ?? null;
}, [messages, seenAtOpen, user]);
// Keep the stored "last seen" caught up to the newest message we've rendered,
// so the next visit only flags genuinely newer messages.
useEffect(() => {
if (messages.length === 0) return;
const newest = messages.reduce((max, m) => Math.max(max, m.createdAt), 0);
if (newest > (lastSeen ?? 0)) markSeen(newest);
}, [messages, lastSeen, markSeen]);
// Auto-scroll to newest message when the list grows.
const lastCount = useRef(0);
useEffect(() => {
const count = messages?.length ?? 0;
if (count !== lastCount.current) {
lastCount.current = count;
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}
}, [messages]);
const canSendNip44 = checkNip44(user, logins);
// How many signer approvals one Send will cost — used to warn Amber users so
// the repeated Approve screens don't look like a bug. Roster = founder + all
// members (deduped, includes self). Cordn v2: one author-proof signature plus
// one encryption per roster member. NIP-17: each target (others + self) is an
// encrypt + a seal signature, i.e. two ops per member.
const rosterSize = Math.max(participants.length, 1);
const approvalCount = usingCordn ? rosterSize + 1 : rosterSize * 2;
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
const text = draft.trim();
if (!text || !user) return;
if (!canSendNip44) {
toast({
title: 'Encryption unavailable',
description: 'Your signer does not support NIP-44 encryption. Please upgrade your extension.',
variant: 'destructive',
});
return;
}
// Cordn transport: seal to the roster and route via the coordinator.
if (usingCordn) {
try {
await cordn.send(text);
setDraft('');
} catch (err) {
toast({
title: 'Message failed',
description: err instanceof Error ? err.message : 'Could not send message.',
variant: 'destructive',
});
}
return;
}
// NIP-17 fallback: recipients = everyone except yourself (hook wraps self too).
const recipients = participants.filter((p) => p !== user.pubkey);
if (recipients.length === 0) {
toast({
title: 'No one to message yet',
description: 'This crew has no other members to chat with.',
});
return;
}
try {
await sendNip17({
recipients,
content: text,
subject: `${crewName} crew chat`,
});
setDraft('');
} catch (err) {
toast({
title: 'Message failed',
description: err instanceof Error ? err.message : 'Could not send message.',
variant: 'destructive',
});
}
};
return (
<Card className="bg-black/90 border-2 border-teal-500/20 flex flex-col overflow-hidden">
<CardHeader className="pb-3 border-b border-white/5">
<div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<MessageSquare className="h-5 w-5 text-teal-400" /> Crew Chat
</CardTitle>
{usingCordn ? (
<Badge
variant="outline"
className="gap-1 border-emerald-500/40 bg-emerald-500/10 text-emerald-300 text-[10px] font-bold uppercase tracking-wide"
title="Routed through this crew's Cordn coordinator over ContextVM. End-to-end encrypted."
>
<Server className="h-3 w-3" />
Cordn
</Badge>
) : (
<Badge
variant="outline"
className="gap-1 border-teal-500/40 bg-teal-500/10 text-teal-400 text-[10px] font-bold uppercase tracking-wide"
title="End-to-end encrypted — only crew members can read these messages."
>
<Lock className="h-3 w-3" />
Private
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1 pt-0.5">
<ShieldCheck className="h-3 w-3 text-teal-400 shrink-0" />
{usingCordn
? 'Encrypted & routed through the crew coordinator (Cordn) — no messages hit public relays.'
: 'Encrypted group chat for the founder & all listed crew members.'}
{canModerate && ' You can hide messages from the crew as a moderator.'}
</p>
</CardHeader>
<CardContent className="p-0 flex flex-col flex-1 min-h-0">
{!user ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 px-6 text-center">
<Lock className="h-8 w-8 text-teal-400/40" />
<p className="text-sm text-muted-foreground">Log in to see if you can join the crew chat.</p>
</div>
) : !isMember ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 px-6 text-center">
<Lock className="h-8 w-8 text-teal-400/40" />
<p className="text-sm text-muted-foreground max-w-xs">
This chat is private to crew members. Ask the founder to add you to the crew to join
the conversation.
</p>
</div>
) : (
<>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto px-4 py-3 space-y-3 min-h-[280px] max-h-[440px]"
>
{isLoading &&
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex gap-2.5">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-8 w-40 rounded-2xl" />
</div>
</div>
))}
{!isLoading && (!messages || messages.length === 0) && (
<div className="flex h-full flex-col items-center justify-center text-center gap-2 py-8">
<MessageSquare className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground max-w-[16rem]">
No messages yet. Kick off the crew chat — plan a session, share a spot, hype a clip.
</p>
</div>
)}
{messages
?.filter((m) => canModerate || !hiddenIds?.has(m.id))
.map((m) => (
<div key={m.id}>
{m.id === firstUnreadId && (
<div className="flex items-center gap-2 py-1.5" aria-label="New messages">
<span className="h-px flex-1 bg-teal-500/30" />
<span className="text-[10px] font-bold uppercase tracking-wide text-teal-400">
New
</span>
<span className="h-px flex-1 bg-teal-500/30" />
</div>
)}
<CrewMessageBubble
message={m}
isOwn={m.pubkey === user.pubkey}
isHidden={hiddenIds?.has(m.id)}
canModerate={canModerate}
onHide={handleHideMessage}
/>
</div>
))}
</div>
<div className="border-t border-white/5 p-3">
<form onSubmit={handleSend} className="flex items-end gap-2">
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend(e);
}
}}
placeholder="Message your crew…"
maxLength={2000}
rows={1}
className="flex-1 resize-none min-h-[40px] max-h-32"
/>
<Button
type="submit"
size="icon"
disabled={isPending || !draft.trim()}
aria-label="Send message"
className="bg-teal-500 hover:bg-teal-600 text-black shrink-0"
>
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</form>
<AmberApprovalHint
approvals={approvalCount}
action="send to your crew"
className="mt-2"
/>
</div>
</>
)}
</CardContent>
</Card>
);
}