diff --git a/NIP.md b/NIP.md index f519696f..f96ecb5c 100644 --- a/NIP.md +++ b/NIP.md @@ -22,6 +22,23 @@ | Protocol | Composed Kinds | Description | |--------------------------|-----------------------------------------|-----------------------------------------------------------------| | Flat Communities | 34550, 30009, 8, 1111, 1984 | One-level badge membership with explicit moderators (NIP-72 ext) | +| Community Chat | 34550, 1311 | Realtime member chat scoped to a NIP-72 community | + +### Community Chat + +Agora uses NIP-53 live chat messages (`kind:1311`) for realtime chat inside a NIP-72 community. Messages are scoped directly to the community definition's address using an `a` tag: + +```json +{ + "kind": 1311, + "content": "Hello community!", + "tags": [ + ["a", "34550::", "", "root"] + ] +} +``` + +Clients SHOULD query community chat with `{ "kinds": [1311], "#a": ["34550::"] }`. Agora treats sending as members-only at the UI layer and applies the same community moderation overlay used for community posts. ### Community Kinds diff --git a/src/components/CommunityChatPanel.tsx b/src/components/CommunityChatPanel.tsx new file mode 100644 index 00000000..989ecd7a --- /dev/null +++ b/src/components/CommunityChatPanel.tsx @@ -0,0 +1,253 @@ +import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'; +import { Link } from 'react-router-dom'; +import { MessageCircle, Send } from 'lucide-react'; +import { useQueryClient } from '@tanstack/react-query'; +import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Textarea } from '@/components/ui/textarea'; +import { NoteContent } from '@/components/NoteContent'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useCommunityChatMessages, COMMUNITY_CHAT_KIND } from '@/hooks/useCommunityChatMessages'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useProfileUrl } from '@/hooks/useProfileUrl'; +import { useToast } from '@/hooks/useToast'; +import { getAvatarShape } from '@/lib/avatarShape'; +import { getDisplayName } from '@/lib/getDisplayName'; +import type { CommunityMember, CommunityModeration } from '@/lib/communityUtils'; +import { cn } from '@/lib/utils'; + +interface CommunityChatPanelProps { + communityATag: string; + moderation: CommunityModeration; + rankMap: ReadonlyMap; + isMembershipLoading: boolean; +} + +function shortTimeAgo(timestamp: number): string { + const diff = Math.max(0, Math.floor(Date.now() / 1000) - timestamp); + if (diff < 60) return 'now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h`; + return `${Math.floor(diff / 86400)}d`; +} + +export function CommunityChatPanel({ + communityATag, + moderation, + rankMap, + isMembershipLoading, +}: CommunityChatPanelProps) { + const queryClient = useQueryClient(); + const { user } = useCurrentUser(); + const { toast } = useToast(); + const { mutateAsync: publishEvent, isPending } = useNostrPublish(); + const { data: messages, isLoading, isError, error, queryKey } = useCommunityChatMessages(communityATag, moderation); + const [message, setMessage] = useState(''); + const scrollRef = useRef(null); + const shouldAutoScrollRef = useRef(true); + + const isBanned = !!user && moderation.bannedPubkeys.has(user.pubkey); + const isMember = !!user && rankMap.has(user.pubkey) && !isBanned; + const disabledReason = !user + ? 'Log in to chat with this community.' + : isMembershipLoading + ? 'Loading membership...' + : isBanned + ? 'You are banned from this community.' + : !isMember + ? 'Only community members can chat.' + : undefined; + const canSend = !disabledReason; + + useEffect(() => { + if (!shouldAutoScrollRef.current || !scrollRef.current) return; + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + }, [messages.length]); + + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + shouldAutoScrollRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80; + }, []); + + const handleSend = useCallback(async () => { + const content = message.trim(); + if (!content || !canSend || isPending) return; + + try { + setMessage(''); + const event = await publishEvent({ + kind: COMMUNITY_CHAT_KIND, + content, + tags: [['a', communityATag, '', 'root']], + }); + + queryClient.setQueryData(queryKey, (old = []) => { + if (old.some((existing) => existing.id === event.id)) return old; + return [...old, event].sort((a, b) => a.created_at - b.created_at); + }); + } catch { + setMessage(content); + toast({ + title: 'Failed to send message', + description: 'Please try again in a moment.', + variant: 'destructive', + }); + } + }, [message, canSend, isPending, publishEvent, communityATag, queryClient, queryKey, toast]); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + void handleSend(); + } + }; + + return ( +
+
+
+ +
+

Community Chat

+

Fast, realtime messages for members.

+
+ + {messages.length} message{messages.length === 1 ? '' : 's'} + +
+ +
+ {isLoading ? ( + + ) : isError ? ( +
+ {error instanceof Error ? error.message : 'Failed to load community chat.'} +
+ ) : messages.length === 0 ? ( +
+
+ +
+

No messages yet

+

Start the first live conversation here.

+
+ ) : ( +
+ {messages.map((event, index) => { + const previous = messages[index - 1]; + const showAvatar = !previous + || previous.pubkey !== event.pubkey + || event.created_at - previous.created_at > 300; + return ; + })} +
+ )} +
+ +
+ {disabledReason && ( +

{disabledReason}

+ )} +
+