mirror of
https://github.com/lumina-rocks/lumina.git
synced 2026-06-06 10:41:20 +02:00
feat: implement chat interface with message input and list components
This commit is contained in:
23
app/chat/[pubkey]/page.tsx
Normal file
23
app/chat/[pubkey]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
components/chat/ChatHeader.tsx
Normal file
50
components/chat/ChatHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
components/chat/ChatInterface.tsx
Normal file
65
components/chat/ChatInterface.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
components/chat/MessageInput.tsx
Normal file
78
components/chat/MessageInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
components/chat/MessageList.tsx
Normal file
56
components/chat/MessageList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user