- 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.
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { formatDistanceToNowStrict } from '@/lib/dateFnsI18n';
|
|
import { Link } from 'react-router-dom';
|
|
import { nip19 } from 'nostr-tools';
|
|
import { MessageCircle, Send, Radio, Info, Globe } from 'lucide-react';
|
|
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import {
|
|
ToggleGroup,
|
|
ToggleGroupItem,
|
|
} from '@/components/ui/toggle-group';
|
|
import { LoginArea } from '@/components/auth/LoginArea';
|
|
import { NoteContent } from '@/components/NoteContent';
|
|
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { TrustFilter } from '@/components/TrustFilter';
|
|
import {
|
|
filterByTrustLevel,
|
|
type TrustFilterLevel,
|
|
} from '@/lib/trustFilter';
|
|
import {
|
|
useSpotChat,
|
|
useSendSpotChat,
|
|
channelGeohash,
|
|
CHAT_PRECISION_LABEL,
|
|
type ChatPrecision,
|
|
type SpotChatMessage,
|
|
} from '@/hooks/useSpotChat';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface SpotChatProps {
|
|
/** The geohash to anchor this chat to (from a spot or the map center). */
|
|
geohash: string;
|
|
/** Whether to show the Block/Neighborhood/City precision selector. */
|
|
showPrecisionToggle?: boolean;
|
|
/** Initial precision. */
|
|
defaultPrecision?: ChatPrecision;
|
|
className?: string;
|
|
}
|
|
|
|
/** A single chat message row, resolving the author's profile. */
|
|
function ChatMessageRow({
|
|
message,
|
|
isOwn,
|
|
}: {
|
|
message: SpotChatMessage;
|
|
isOwn: boolean;
|
|
}) {
|
|
const author = useAuthor(message.pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const name = metadata?.name ?? genUserName(message.pubkey);
|
|
const npub = useMemo(() => nip19.npubEncode(message.pubkey), [message.pubkey]);
|
|
|
|
if (message.isAction) {
|
|
return (
|
|
<div className="px-1 py-1 text-sm text-muted-foreground italic">
|
|
<span aria-hidden>✳ </span>
|
|
<Link to={`/${npub}`} className="font-medium text-foreground hover:underline not-italic">
|
|
{name}
|
|
</Link>{' '}
|
|
{message.content}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn('flex gap-2.5 py-1.5', isOwn && 'flex-row-reverse')}>
|
|
<Link to={`/${npub}`} className="shrink-0">
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarImage src={metadata?.picture} alt={name} />
|
|
<AvatarFallback className="text-xs">
|
|
{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">
|
|
{formatDistanceToNowStrict(message.createdAt * 1000, { addSuffix: true })}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
'rounded-2xl px-3 py-2 text-sm break-words',
|
|
isOwn
|
|
? 'bg-orange-500 text-white rounded-tr-sm'
|
|
: 'bg-muted rounded-tl-sm',
|
|
)}
|
|
>
|
|
<NoteContent
|
|
event={{
|
|
id: message.id,
|
|
pubkey: message.pubkey,
|
|
content: message.content,
|
|
created_at: message.createdAt,
|
|
kind: 24899,
|
|
tags: [],
|
|
sig: '',
|
|
}}
|
|
className="text-sm [&_a]:underline"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SpotChat({
|
|
geohash,
|
|
showPrecisionToggle = false,
|
|
defaultPrecision = 'block',
|
|
className,
|
|
}: SpotChatProps) {
|
|
const { t } = useTranslation();
|
|
const { user } = useCurrentUser();
|
|
const { toast } = useToast();
|
|
const [precision, setPrecision] = useState<ChatPrecision>(defaultPrecision);
|
|
const [trustLevel, setTrustLevel] = useState<TrustFilterLevel>('all');
|
|
const [draft, setDraft] = useState('');
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
const { data: rawMessages, isLoading } = useSpotChat(geohash, precision);
|
|
const { mutateAsync: sendMessage, isPending } = useSendSpotChat();
|
|
|
|
const channel = channelGeohash(geohash, precision);
|
|
|
|
// Trust scores for everyone who posted in this channel.
|
|
const authorPubkeys = useMemo(
|
|
() => Array.from(new Set((rawMessages ?? []).map((m) => m.pubkey))),
|
|
[rawMessages],
|
|
);
|
|
const { data: trustScores } = useBatchTrustScores(
|
|
trustLevel === 'all' ? [] : authorPubkeys,
|
|
);
|
|
|
|
// Apply the trust filter. The user's own messages are always kept so their
|
|
// sent messages never vanish behind the filter.
|
|
const messages = useMemo(() => {
|
|
if (!rawMessages) return rawMessages;
|
|
if (trustLevel === 'all') return rawMessages;
|
|
const filtered = filterByTrustLevel(
|
|
rawMessages,
|
|
trustLevel,
|
|
(pk) => trustScores?.get(pk),
|
|
);
|
|
const kept = new Set(filtered.map((m) => m.id));
|
|
return rawMessages.filter((m) => kept.has(m.id) || m.pubkey === user?.pubkey);
|
|
}, [rawMessages, trustLevel, trustScores, user?.pubkey]);
|
|
|
|
const hiddenCount = (rawMessages?.length ?? 0) - (messages?.length ?? 0);
|
|
|
|
// 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 handleSend = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const text = draft.trim();
|
|
if (!text) return;
|
|
|
|
// IRC-style /who — list recent participants (easter-egg power-user command).
|
|
if (text === '/who' || text.startsWith('/who ')) {
|
|
const recent = new Set((rawMessages ?? []).map((m) => m.pubkey));
|
|
toast({
|
|
title:
|
|
recent.size === 1
|
|
? t('spotUi.skatersActiveOne', { count: recent.size })
|
|
: t('spotUi.skatersActiveOther', { count: recent.size }),
|
|
description:
|
|
recent.size > 0
|
|
? t('spotUi.skatersActiveDesc')
|
|
: t('spotUi.beFirstToSay'),
|
|
});
|
|
setDraft('');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await sendMessage({ geohash, precision, content: text });
|
|
setDraft('');
|
|
} catch (err) {
|
|
toast({
|
|
title: t('spotUi.messageFailed'),
|
|
description: err instanceof Error ? err.message : t('spotUi.couldNotSendMessage'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card className={cn('flex flex-col overflow-hidden', className)}>
|
|
<CardHeader className="pb-3 border-b">
|
|
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
|
|
<CardTitle className="text-lg flex flex-wrap items-center gap-2 min-w-0">
|
|
<MessageCircle className="h-5 w-5 text-orange-500" />
|
|
{t('spotUi.spotChat')}
|
|
<Badge
|
|
variant="outline"
|
|
className="gap-1 border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] font-bold uppercase tracking-wide"
|
|
title={t('spotUi.publicChatTitle')}
|
|
>
|
|
<Globe className="h-3 w-3" />
|
|
{t('spotUi.public')}
|
|
</Badge>
|
|
</CardTitle>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<TrustFilter value={trustLevel} onChange={setTrustLevel} compact />
|
|
<Badge variant="secondary" className="font-mono text-[10px] gap-1">
|
|
<Radio className="h-3 w-3" />#{channel}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
{showPrecisionToggle && (
|
|
<div className="pt-1">
|
|
<ToggleGroup
|
|
type="single"
|
|
value={precision}
|
|
onValueChange={(v) => v && setPrecision(v as ChatPrecision)}
|
|
className="justify-start"
|
|
size="sm"
|
|
variant="outline"
|
|
>
|
|
{(Object.keys(CHAT_PRECISION_LABEL) as ChatPrecision[]).map((p) => (
|
|
<ToggleGroupItem key={p} value={p} className="text-xs px-3">
|
|
{CHAT_PRECISION_LABEL[p]}
|
|
</ToggleGroupItem>
|
|
))}
|
|
</ToggleGroup>
|
|
</div>
|
|
)}
|
|
</CardHeader>
|
|
|
|
<CardContent className="p-0 flex flex-col flex-1 min-h-0">
|
|
<div
|
|
ref={scrollRef}
|
|
className="flex-1 overflow-y-auto px-4 py-3 space-y-0.5 min-h-[280px] max-h-[440px]"
|
|
>
|
|
{isLoading && (
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
{t('spotUi.loadingChat')}
|
|
</div>
|
|
)}
|
|
{!isLoading && (!messages || messages.length === 0) && (
|
|
<div className="flex h-full flex-col items-center justify-center text-center gap-2 py-8">
|
|
<MessageCircle className="h-8 w-8 text-muted-foreground/40" />
|
|
{hiddenCount > 0 ? (
|
|
<p className="text-sm text-muted-foreground max-w-[16rem]">
|
|
{hiddenCount === 1
|
|
? t('spotUi.messagesHiddenOne', { count: hiddenCount })
|
|
: t('spotUi.messagesHiddenOther', { count: hiddenCount })}
|
|
</p>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground max-w-[16rem]">
|
|
{t('spotUi.noMessagesYet')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
{messages?.map((m) => (
|
|
<ChatMessageRow key={m.id} message={m} isOwn={m.pubkey === user?.pubkey} />
|
|
))}
|
|
{!isLoading && messages && messages.length > 0 && hiddenCount > 0 && (
|
|
<div className="pt-2 text-center text-[11px] text-muted-foreground">
|
|
{hiddenCount === 1
|
|
? t('spotUi.messagesHiddenShortOne', { count: hiddenCount })
|
|
: t('spotUi.messagesHiddenShortOther', { count: hiddenCount })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t p-3">
|
|
{user ? (
|
|
<>
|
|
<form onSubmit={handleSend} className="flex items-center gap-2">
|
|
<Input
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
placeholder={t('spotUi.messagePlaceholder')}
|
|
maxLength={500}
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
size="icon"
|
|
disabled={isPending || !draft.trim()}
|
|
className="bg-orange-500 hover:bg-orange-600 shrink-0"
|
|
>
|
|
<Send className="h-4 w-4" />
|
|
</Button>
|
|
</form>
|
|
<p className="mt-1.5 flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
<Info className="h-3 w-3 shrink-0" />
|
|
{t('spotUi.chatPublicNote')}
|
|
</p>
|
|
</>
|
|
) : (
|
|
<div className="flex flex-col items-center gap-2 py-1 text-center">
|
|
<p className="text-sm text-muted-foreground">{t('spotUi.logInToChat')}</p>
|
|
<LoginArea className="max-w-60" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|