feat: implement chat interface with message input and list components

This commit is contained in:
2025-04-20 22:44:06 +02:00
parent 9628c59c37
commit 02c9d0b3bd
5 changed files with 272 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
import { Metadata } from 'next';
import ChatInterface from '@/components/chat/ChatInterface';
export const metadata: Metadata = {
title: 'Chat | Lumina',
description: 'Direct message chat with a Nostr user',
};
interface ChatPageProps {
params: {
pubkey: string;
}
}
export default function ChatPage({ params }: ChatPageProps) {
const { pubkey } = params;
return (
<div className="container mx-auto px-4 py-4 h-[calc(100vh-8rem)]">
<ChatInterface pubkey={pubkey} />
</div>
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import { useEffect, useState } from 'react';
import { useProfile } from 'nostr-react';
import { ChevronLeft } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
interface ChatHeaderProps {
pubkey: string;
}
export default function ChatHeader({ pubkey }: ChatHeaderProps) {
const router = useRouter();
const { data: userData } = useProfile({
pubkey,
});
const [displayName, setDisplayName] = useState<string>(pubkey.substring(0, 8) + '...');
useEffect(() => {
if (userData?.name) {
setDisplayName(userData.name);
}
}, [userData]);
return (
<div className="flex items-center p-3 border-b border-border bg-card">
<button
onClick={() => router.back()}
className="p-2 rounded-full hover:bg-muted mr-2"
aria-label="Go back"
>
<ChevronLeft className="h-5 w-5" />
</button>
<Avatar className="h-10 w-10">
<AvatarImage src={userData?.picture || ''} alt={displayName} />
<AvatarFallback>{displayName.substring(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="ml-3 flex-1 overflow-hidden">
<h3 className="font-medium text-foreground truncate">{displayName}</h3>
{userData?.nip05 && (
<p className="text-xs text-muted-foreground truncate">{userData.nip05}</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import { useState, useEffect } from 'react';
import { useNostrEvents, dateToUnix } from 'nostr-react';
import ChatHeader from './ChatHeader';
import MessageList from './MessageList';
import MessageInput from './MessageInput';
interface ChatInterfaceProps {
pubkey: string;
}
export default function ChatInterface({ pubkey }: ChatInterfaceProps) {
const [messages, setMessages] = useState<any[]>([]);
const now = new Date();
// Fetch direct messages (kind 4) between the current user and the specified pubkey
const { events } = useNostrEvents({
filter: {
kinds: [4], // Kind 4 is direct messages in Nostr
authors: [/* would be populated with current user pubkey */],
since: dateToUnix(new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)), // Last 7 days
},
});
// For demo purposes, let's add some dummy messages
useEffect(() => {
const dummyMessages = [
{ id: '1', sender: 'you', content: 'Hello there!', timestamp: new Date(now.getTime() - 3600000) },
{ id: '2', sender: pubkey, content: 'Hi! How are you?', timestamp: new Date(now.getTime() - 3500000) },
{ id: '3', sender: 'you', content: 'I\'m doing well, thanks for asking!', timestamp: new Date(now.getTime() - 3400000) },
{ id: '4', sender: pubkey, content: 'That\'s great to hear! What have you been up to?', timestamp: new Date(now.getTime() - 3300000) },
{ id: '5', sender: 'you', content: 'Just exploring the Nostr protocol and building some cool stuff with it.', timestamp: new Date(now.getTime() - 3200000) },
{ id: '6', sender: pubkey, content: 'That sounds interesting! I\'ve been hearing a lot about Nostr lately.', timestamp: new Date(now.getTime() - 3100000) },
{ id: '7', sender: 'you', content: 'Yeah, it\'s a fascinating protocol for decentralized social networking. Very simple yet powerful.', timestamp: new Date(now.getTime() - 3000000) },
{ id: '8', sender: pubkey, content: 'I should look into it more. Any resources you recommend?', timestamp: new Date(now.getTime() - 2900000) },
{ id: '9', sender: 'you', content: 'Check out the NIPs (Nostr Implementation Possibilities) on GitHub, and there are several good clients to try out.', timestamp: new Date(now.getTime() - 2800000) },
{ id: '10', sender: pubkey, content: 'Thanks for the tip! I\'ll definitely check those out.', timestamp: new Date(now.getTime() - 2700000) },
];
setMessages(dummyMessages);
}, [pubkey]);
const handleSendMessage = (message: string) => {
if (!message.trim()) return;
// In a real implementation, this would publish to Nostr
const newMessage = {
id: Date.now().toString(),
sender: 'you',
content: message,
timestamp: new Date(),
};
setMessages(prev => [...prev, newMessage]);
};
return (
<div className="flex flex-col h-full border border-border rounded-lg overflow-hidden">
<ChatHeader pubkey={pubkey} />
<MessageList messages={messages} currentUserPubkey="you" />
<MessageInput onSendMessage={handleSendMessage} />
</div>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import { useState, useRef } from 'react';
import { SendHorizonal, PaperclipIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface MessageInputProps {
onSendMessage: (message: string) => void;
}
export default function MessageInput({ onSendMessage }: MessageInputProps) {
const [message, setMessage] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (message.trim()) {
onSendMessage(message);
setMessage('');
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Send on Enter (without shift for a new line)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
// Auto-resize textarea based on content
const handleInput = () => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, 150)}px`;
}
};
return (
<form onSubmit={handleSubmit} className="border-t border-border p-3 bg-card">
<div className="flex items-end gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full h-9 w-9 flex-shrink-0"
aria-label="Attach file"
>
<PaperclipIcon className="h-5 w-5 text-muted-foreground" />
</Button>
<div className="relative flex-1">
<textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder="Type a message..."
className="w-full resize-none rounded-2xl border border-input bg-background px-4 py-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring min-h-[40px] max-h-[150px]"
rows={1}
/>
</div>
<Button
type="submit"
size="icon"
className="rounded-full h-9 w-9 flex-shrink-0"
aria-label="Send message"
disabled={!message.trim()}
>
<SendHorizonal className="h-5 w-5" />
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { useRef, useEffect } from 'react';
import { ScrollArea } from '@/components/ui/scroll-area';
interface Message {
id: string;
sender: string;
content: string;
timestamp: Date;
}
interface MessageListProps {
messages: Message[];
currentUserPubkey: string;
}
export default function MessageList({ messages, currentUserPubkey }: MessageListProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<ScrollArea className="flex-1 p-4">
<div className="space-y-4">
{messages.map((message) => {
const isCurrentUser = message.sender === currentUserPubkey;
return (
<div
key={message.id}
className={`flex ${isCurrentUser ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] md:max-w-[70%] rounded-lg px-4 py-2 ${
isCurrentUser
? 'bg-primary text-primary-foreground rounded-br-none'
: 'bg-muted text-muted-foreground rounded-bl-none'
}`}
>
<div className="break-words">{message.content}</div>
<div className={`text-xs mt-1 ${isCurrentUser ? 'text-primary-foreground/80' : 'text-muted-foreground/80'}`}>
{new Date(message.timestamp).toLocaleString()}
</div>
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
);
}