- New src/lib/dateFnsI18n.ts re-exports date-fns with formatDistanceToNow /
formatDistanceToNowStrict / formatDistance / format defaulting to the active
locale, plus dateLocaleCode() for native toLocale{Date,Time}String calls.
- LanguageProvider keeps the locale in sync during render, so the first paint is
already in-language.
- Codemod: 11 date-fns imports now use the shim; ~39 native date/time calls pass
the app locale code instead of undefined/[]/hardcoded 'en-US'. Number
.toLocaleString() calls are deliberately untouched.
458 lines
17 KiB
TypeScript
458 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 '@/lib/dateFnsI18n';
|
|
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 { useTranslation } from '@/contexts/language';
|
|
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 { t } = useTranslation();
|
|
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" /> {t('socialUi.chat.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={t('socialUi.chat.hideMessageAria')}
|
|
title={t('socialUi.chat.hideMessageTitle')}
|
|
>
|
|
<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 { t } = useTranslation();
|
|
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: t('socialUi.chat.messageHidden'), description: t('socialUi.chat.messageHiddenDesc') });
|
|
} catch (err) {
|
|
toast({
|
|
title: t('socialUi.chat.messageHideFailed'),
|
|
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
|
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: t('socialUi.chat.encryptionUnavailable'),
|
|
description: t('socialUi.chat.encryptionUnavailableDesc'),
|
|
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: t('socialUi.chat.messageFailed'),
|
|
description: err instanceof Error ? err.message : t('socialUi.chat.couldNotSend'),
|
|
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: t('socialUi.chat.noOneToMessage'),
|
|
description: t('socialUi.chat.noOneToMessageDesc'),
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await sendNip17({
|
|
recipients,
|
|
content: text,
|
|
subject: t('socialUi.chat.subject', { crew: crewName }),
|
|
});
|
|
setDraft('');
|
|
} catch (err) {
|
|
toast({
|
|
title: t('socialUi.chat.messageFailed'),
|
|
description: err instanceof Error ? err.message : t('socialUi.chat.couldNotSend'),
|
|
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" /> {t('socialUi.chat.title')}
|
|
</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={t('socialUi.chat.cordnBadgeTitle')}
|
|
>
|
|
<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={t('socialUi.chat.privateBadgeTitle')}
|
|
>
|
|
<Lock className="h-3 w-3" />
|
|
{t('socialUi.chat.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
|
|
? t('socialUi.chat.encryptedCordn')
|
|
: t('socialUi.chat.encryptedNip17')}
|
|
{canModerate && t('socialUi.chat.moderatorNote')}
|
|
</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">{t('socialUi.chat.loginToSee')}</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">
|
|
{t('socialUi.chat.membersOnly')}
|
|
</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]">
|
|
{t('socialUi.chat.noMessages')}
|
|
</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={t('socialUi.chat.newMessages')}>
|
|
<span className="h-px flex-1 bg-teal-500/30" />
|
|
<span className="text-[10px] font-bold uppercase tracking-wide text-teal-400">
|
|
{t('socialUi.chat.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={t('socialUi.chat.placeholder')}
|
|
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={t('socialUi.chat.sendAria')}
|
|
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>
|
|
);
|
|
}
|