SkateSpots/src/components/SpotChat.tsx
shakespeare.diy e122ee826b Add "Public" badge to Spot Chat header + clearer privacy note
Makes it unmistakable that Spot Chat messages are public and unencrypted:
- Amber "🌐 Public" badge next to the title with an explanatory tooltip
- Footer note reworded to point users to Direct Messages for private chats

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-24 15:12:39 +07:00

320 lines
11 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { formatDistanceToNowStrict } from 'date-fns';
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 { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { genUserName } from '@/lib/genUserName';
import {
TrustFilter,
filterByTrustLevel,
type TrustFilterLevel,
} from '@/components/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 { 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} skater${recent.size === 1 ? '' : 's'} active here`,
description:
recent.size > 0
? 'People who posted in this channel in the last 24h.'
: 'Be the first to say something.',
});
setDraft('');
return;
}
try {
await sendMessage({ geohash, precision, content: text });
setDraft('');
} catch (err) {
toast({
title: 'Message failed',
description: err instanceof Error ? err.message : 'Could not send message.',
variant: 'destructive',
});
}
};
return (
<Card className={cn('flex flex-col overflow-hidden', className)}>
<CardHeader className="pb-3 border-b">
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-lg flex items-center gap-2">
<MessageCircle className="h-5 w-5 text-orange-500" />
Spot Chat
<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="Anyone can read messages in this channel — it is public and unencrypted."
>
<Globe className="h-3 w-3" />
Public
</Badge>
</CardTitle>
<div className="flex 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">
Loading chat…
</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} message{hiddenCount === 1 ? '' : 's'} hidden by your trust filter.
Lower the filter to see more.
</p>
) : (
<p className="text-sm text-muted-foreground max-w-[16rem]">
No messages yet in this local channel. Say hi — "anyone skating tonight?"
</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} message{hiddenCount === 1 ? '' : 's'} hidden by your trust filter
</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="Message this spot… (try /me or /who)"
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" />
Anyone can read this — messages are public &amp; ephemeral. For private
chats, use Direct Messages.
</p>
</>
) : (
<div className="flex flex-col items-center gap-2 py-1 text-center">
<p className="text-sm text-muted-foreground">Log in to join the local chat.</p>
<LoginArea className="max-w-60" />
</div>
)}
</div>
</CardContent>
</Card>
);
}