Add community chat tab
This commit is contained in:
@@ -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:<community-author-pubkey>:<community-d-tag>", "", "root"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Clients SHOULD query community chat with `{ "kinds": [1311], "#a": ["34550:<pubkey>:<d-tag>"] }`. Agora treats sending as members-only at the UI layer and applies the same community moderation overlay used for community posts.
|
||||
|
||||
### Community Kinds
|
||||
|
||||
|
||||
@@ -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<string, CommunityMember>;
|
||||
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<HTMLDivElement>(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<NostrEvent[]>(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<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4">
|
||||
<div className="overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<MessageCircle className="size-4 text-primary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold leading-none">Community Chat</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Fast, realtime messages for members.</p>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{messages.length} message{messages.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-[min(68vh,560px)] overflow-y-auto px-3 py-3"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{isLoading ? (
|
||||
<CommunityChatSkeleton />
|
||||
) : isError ? (
|
||||
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-destructive">
|
||||
{error instanceof Error ? error.message : 'Failed to load community chat.'}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
|
||||
<div className="mb-3 rounded-full bg-primary/10 p-3">
|
||||
<MessageCircle className="size-6 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">No messages yet</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Start the first live conversation here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{messages.map((event, index) => {
|
||||
const previous = messages[index - 1];
|
||||
const showAvatar = !previous
|
||||
|| previous.pubkey !== event.pubkey
|
||||
|| event.created_at - previous.created_at > 300;
|
||||
return <CommunityChatMessage key={event.id} event={event} showAvatar={showAvatar} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-3">
|
||||
{disabledReason && (
|
||||
<p className="mb-2 text-center text-xs text-muted-foreground">{disabledReason}</p>
|
||||
)}
|
||||
<div className="flex items-end gap-2">
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Message the community..."
|
||||
disabled={!canSend || isPending}
|
||||
maxLength={1000}
|
||||
className="max-h-32 min-h-11 resize-none rounded-xl text-base md:text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="size-11 shrink-0 rounded-xl"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!message.trim() || !canSend || isPending}
|
||||
aria-label="Send chat message"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityChatSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 p-2">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<Skeleton className="size-8 rounded-full" />
|
||||
<div className="flex-1 space-y-2 pt-1">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className={cn('h-4', index % 2 === 0 ? 'w-4/5' : 'w-2/3')} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityChatMessage({ event, showAvatar }: { event: NostrEvent; showAvatar: boolean }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata: NostrMetadata | undefined = author.data?.metadata;
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
|
||||
return (
|
||||
<div className={cn('group flex gap-3 rounded-xl px-2 py-1.5 transition-colors hover:bg-secondary/40', !showAvatar && 'py-0.5')}>
|
||||
<div className="w-8 shrink-0">
|
||||
{showAvatar ? (
|
||||
<Link to={profileUrl} onClick={(event) => event.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-8">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/15 text-[10px] text-primary">
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="hidden pt-0.5 text-[10px] text-muted-foreground/60 group-hover:block">
|
||||
{shortTimeAgo(event.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{showAvatar && (
|
||||
<div className="mb-0.5 flex items-baseline gap-2">
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="truncate text-xs font-semibold text-primary hover:underline"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{displayName}
|
||||
</Link>
|
||||
<span className="text-[10px] text-muted-foreground/60">{shortTimeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="break-words text-sm leading-relaxed">
|
||||
<NoteContent
|
||||
event={event}
|
||||
className="inline"
|
||||
disableEmbeds
|
||||
disableMediaEmbeds
|
||||
disableNoteEmbeds
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { BanConfirmDialog } from '@/components/BanConfirmDialog';
|
||||
import { CommunityChatPanel } from '@/components/CommunityChatPanel';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
import { FeedEmptyState } from '@/components/FeedEmptyState';
|
||||
import { CreateGoalDialog } from '@/components/CreateGoalDialog';
|
||||
@@ -574,6 +575,13 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
<Users className="size-4 mr-1.5" />
|
||||
Members
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="chat"
|
||||
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
|
||||
>
|
||||
<MessageCircle className="size-4 mr-1.5" />
|
||||
Chat
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="feed"
|
||||
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
|
||||
@@ -651,6 +659,22 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Chat tab ── */}
|
||||
<TabsContent value="chat" className="mt-0">
|
||||
{communityATag ? (
|
||||
<CommunityChatPanel
|
||||
communityATag={communityATag}
|
||||
moderation={moderation}
|
||||
rankMap={rankMap}
|
||||
isMembershipLoading={membersLoading}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
||||
Community chat is unavailable for this community.
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Feed tab ── */}
|
||||
<TabsContent value="feed" className="mt-0">
|
||||
<CommunityMemberFeed
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { applyCommunityModerationToEvents, type CommunityModeration } from '@/lib/communityUtils';
|
||||
|
||||
export const COMMUNITY_CHAT_KIND = 1311;
|
||||
|
||||
function isCommunityChatMessage(event: NostrEvent, communityATag: string): boolean {
|
||||
return event.kind === COMMUNITY_CHAT_KIND
|
||||
&& event.tags.some(([name, value]) => name === 'a' && value === communityATag);
|
||||
}
|
||||
|
||||
export function useCommunityChatMessages(
|
||||
communityATag: string | undefined,
|
||||
moderation?: CommunityModeration,
|
||||
) {
|
||||
const { nostr } = useNostr();
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = useMemo(() => ['community-chat', communityATag ?? ''], [communityATag]);
|
||||
|
||||
const query = useQuery<NostrEvent[]>({
|
||||
queryKey,
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!communityATag) return [];
|
||||
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [COMMUNITY_CHAT_KIND], '#a': [communityATag], limit: 100 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8_000)]) },
|
||||
);
|
||||
|
||||
return events
|
||||
.filter((event) => isCommunityChatMessage(event, communityATag))
|
||||
.sort((a, b) => a.created_at - b.created_at);
|
||||
},
|
||||
enabled: !!communityATag,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!communityATag) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const since = Math.floor(Date.now() / 1000);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
for await (const msg of nostr.req(
|
||||
[{ kinds: [COMMUNITY_CHAT_KIND], '#a': [communityATag], since }],
|
||||
{ signal: controller.signal },
|
||||
)) {
|
||||
if (msg[0] !== 'EVENT') continue;
|
||||
|
||||
const event = msg[2] as NostrEvent;
|
||||
if (!isCommunityChatMessage(event, communityATag)) continue;
|
||||
|
||||
queryClient.setQueryData<NostrEvent[]>(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 (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
console.error('Community chat subscription failed:', error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [nostr, communityATag, queryClient, queryKey]);
|
||||
|
||||
const moderatedMessages = useMemo(() => {
|
||||
const messages = query.data ?? [];
|
||||
return moderation ? applyCommunityModerationToEvents(messages, moderation) : messages;
|
||||
}, [query.data, moderation]);
|
||||
|
||||
return {
|
||||
...query,
|
||||
data: moderatedMessages,
|
||||
queryKey,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user