);
}
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(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(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 (
Crew Chat
{usingCordn ? (
Cordn
) : (
Private
)}
{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.'}
{!user ? (
Log in to see if you can join the crew chat.
) : !isMember ? (
This chat is private to crew members. Ask the founder to add you to the crew to join
the conversation.