From f3d15a731a0532e68278cdccb972e65f445b5dcb Mon Sep 17 00:00:00 2001 From: Dataguru-tech Date: Wed, 25 Feb 2026 01:22:03 +0100 Subject: [PATCH] feat/Real time Message Rendering --- app/chat/page.tsx | 575 ++++++++++--------------------- app/layout.tsx | 30 +- app/stellar-wallet-kit.tsx | 68 ++-- package-lock.json | 194 ++++++++++- src/components/ChatWindow.tsx | 33 ++ src/components/MessageInput.tsx | 33 ++ src/components/MessageItem.tsx | 24 ++ src/components/MessageList.tsx | 27 ++ src/hooks/useChatSubscription.ts | 17 + src/hooks/useMessages.ts | 17 + src/types/message.ts | 7 + 11 files changed, 596 insertions(+), 429 deletions(-) create mode 100644 src/components/ChatWindow.tsx create mode 100644 src/components/MessageInput.tsx create mode 100644 src/components/MessageItem.tsx create mode 100644 src/components/MessageList.tsx create mode 100644 src/hooks/useChatSubscription.ts create mode 100644 src/hooks/useMessages.ts create mode 100644 src/types/message.ts diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 55227ee..aabebec 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Image from "next/image"; import { Header } from "@/components/header"; import { Footer } from "@/components/footer"; @@ -9,16 +9,9 @@ import { type PresenceStatus, } from "@/components/presence-indicator"; import ConnectWallet from "@/components/wallet-connector"; +import { RoomMembersDialog } from "@/components/room-members-dialog"; import { cn } from "@/lib/utils"; -import { useEffect, useMemo, useState } from "react" -import Image from "next/image" -import { Header } from "@/components/header" -import { Footer } from "@/components/footer" -import { PresenceIndicator, type PresenceStatus } from "@/components/presence-indicator" -import ConnectWallet from "@/components/wallet-connector" -import { RoomMembersDialog } from "@/components/room-members-dialog" -import { cn } from "@/lib/utils" -import { getPublicKey, onDisconnect } from "@/app/stellar-wallet-kit" +import { getPublicKey, onDisconnect } from "@/app/stellar-wallet-kit"; import { Search, MessageCircle, @@ -33,11 +26,10 @@ import { MoreVertical, Paperclip, Smile, -} from "lucide-react"; Star, -} from "lucide-react" -import { calculateReputation, trackActivity } from "@/lib/reputation" -import { CONFIG } from "@/lib/config" +} from "lucide-react"; +import { calculateReputation, trackActivity } from "@/lib/reputation"; +import { CONFIG } from "@/lib/config"; type ChatPreview = { id: string; @@ -62,41 +54,51 @@ type ChatMessage = { export default function ChatPage() { const [selectedChatId, setSelectedChatId] = useState(null); const [query, setQuery] = useState(""); - const [messageText, setMessageText] = useState(""); - + const [inputMessage, setInputMessage] = useState(""); const [walletConnected, setWalletConnected] = useState(false); + const [roomMembersOpen, setRoomMembersOpen] = useState(false); + const [currentPublicKey, setCurrentPublicKey] = useState(null); + const [reputationScore, setReputationScore] = useState(0); + const bottomRef = useRef(null); - useEffect(() => { - const el = document.getElementById("connect-wrap"); - if (!el) return; - - const observer = new MutationObserver(() => { - const hasAddress = el.textContent && el.textContent.includes("..."); - setWalletConnected(Boolean(hasAddress)); - }); - - observer.observe(el, { - childList: true, - subtree: true, - characterData: true, - }); - return () => observer.disconnect(); - }, []); - const [selectedChatId, setSelectedChatId] = useState(null) - const [query, setQuery] = useState("") - const [inputMessage, setInputMessage] = useState("") - const [roomMembersOpen, setRoomMembersOpen] = useState(false) - - const [walletConnected, setWalletConnected] = useState(false) - const [currentPublicKey, setCurrentPublicKey] = useState(null) - const [reputationScore, setReputationScore] = useState(0) + const [chats, setChats] = useState([ + { + id: "1", + name: "Anon Whisper", + address: "GABC...1234", + lastMessage: "Got your message, will reply soon.", + lastSeen: "Today - 14:32", + unreadCount: 2, + status: "online", + }, + { + id: "2", + name: "Room #xf23", + address: "GCDE...5678", + lastMessage: "Pinned the latest proposal for review.", + lastSeen: "Today - 09:10", + unreadCount: 0, + status: "recently_active", + }, + { + id: "3", + name: "Collector", + address: "GHJK...9012", + lastMessage: "Let's sync tomorrow.", + lastSeen: "Yesterday - 18:04", + unreadCount: 0, + status: "offline", + }, + ]); - const [messagesByChat, setMessagesByChat] = useState>({ + const [messagesByChat, setMessagesByChat] = useState< + Record + >({ "1": [ { id: "m1", author: "them", - text: "Hey, welcome to AnonChat 👋", + text: "Hey, welcome to AnonChat!", time: "14:20", delivered: true, read: true, @@ -140,7 +142,7 @@ export default function ChatPage() { { id: "m3", author: "them", - text: "Messages stay end‑to‑end encrypted here.", + text: "Messages stay end-to-end encrypted here.", time: "14:25", delivered: true, read: false, @@ -160,107 +162,57 @@ export default function ChatPage() { { id: "m5", author: "me", - text: "Let’s catch up on the drop.", + text: "Let's catch up on the drop.", time: "17:40", delivered: true, read: true, }, ], - }) + }); - // Update reputation score + // Auto-scroll to latest message useEffect(() => { - const updateScore = () => { - setReputationScore(calculateReputation(currentPublicKey)) - } - updateScore() - window.addEventListener("reputationUpdate", updateScore) - return () => window.removeEventListener("reputationUpdate", updateScore) - }, [currentPublicKey]) + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messagesByChat, selectedChatId]); - // Sync wallet state properly + // Sync wallet state useEffect(() => { const checkWallet = async () => { - const address = await getPublicKey() - setWalletConnected(!!address) - checkWallet() - - // Listen for disconnects - const unsubscribe = onDisconnect(() => { - setWalletConnected(false) - setCurrentPublicKey(null) - }) - - // Heuristic: Check on interval or simple event as well since kit doesn't have onConnect yet - const interval = setInterval(checkWallet, 1000) + const address = await getPublicKey(); + setWalletConnected(!!address); + if (address) setCurrentPublicKey(address); + }; + checkWallet(); + const unsubscribe = onDisconnect(() => { + setWalletConnected(false); + setCurrentPublicKey(null); + }); + const interval = setInterval(checkWallet, 3000); + return () => { + unsubscribe(); + clearInterval(interval); + }; + }, []); - const initialChats: ChatPreview[] = useMemo( - () => [ - { - id: "1", - name: "Anon Whisper", - address: "GABC...1234", - lastMessage: "Got your message, will reply soon.", - lastSeen: "Today • 14:32", - unreadCount: 2, - status: "online", - }, - { - id: "2", - name: "Room #xf23", - address: "GCDE...5678", - lastMessage: "Pinned the latest proposal for review.", - lastSeen: "Today • 09:10", - unreadCount: 0, - status: "recently_active", - }, - { - id: "3", - name: "Collector", - address: "GHJK...9012", - lastMessage: "Let’s sync tomorrow.", - lastSeen: "Yesterday • 18:04", - unreadCount: 0, - status: "offline", - }, - ], - [], - ); - return () => { - unsubscribe() - clearInterval(interval) - } - }}, []) + // Update reputation score + useEffect(() => { + const updateScore = () => + setReputationScore(calculateReputation(currentPublicKey)); + updateScore(); + window.addEventListener("reputationUpdate", updateScore); + return () => window.removeEventListener("reputationUpdate", updateScore); + }, [currentPublicKey]); - const [chats, setChats] = useState([ - { - id: "1", - name: "Anon Whisper", - address: "GABC ... 1234", - lastMessage: "Got your message, will reply soon.", - lastSeen: "Today • 14:32", - unreadCount: 2, - status: "online", - }, - { - id: "2", - name: "Room #xf23", - address: "GCDE ... 5678", - lastMessage: "Pinned the latest proposal for review.", - lastSeen: "Today • 09:10", - unreadCount: 0, - status: "recently_active", - }, - { - id: "3", - name: "Collector", - address: "GHJK ... 9012", - lastMessage: "Let’s sync tomorrow.", - lastSeen: "Yesterday • 18:04", - unreadCount: 0, - status: "offline", - }, - ]); + // Listen for new room creation + useEffect(() => { + const handleRoomCreated = (e: any) => { + const newRoom = e.detail; + setChats((prev) => [newRoom, ...prev]); + setSelectedChatId(newRoom.id); + }; + window.addEventListener("roomCreated", handleRoomCreated); + return () => window.removeEventListener("roomCreated", handleRoomCreated); + }, []); const markRoomRead = async (roomId: string) => { try { @@ -268,143 +220,61 @@ export default function ChatPage() { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roomId }), - }) + }); } catch (err) { - console.error("Failed to mark room read", err) + console.error("Failed to mark room read", err); } - } + }; const handleSelectChat = async (id: string) => { - setSelectedChatId(id) - // update server and local unread count - await markRoomRead(id) - setChats((prev) => prev.map((c) => (c.id === id ? { ...c, unreadCount: 0 } : c))) - } - - const messagesByChat: Record = useMemo( - () => ({ - "1": [ - { - id: "m1", - author: "them", - text: "Hey, welcome to AnonChat 👋", - time: "14:20", - delivered: true, - read: true, - }, - { - id: "m2", - author: "me", - text: "Love how clean this feels on desktop.", - time: "14:22", - delivered: false, - read: false, - status: "sending", - }, - { - id: "m2b", - author: "me", - text: "Just sent another update.", - time: "14:23", - delivered: false, - read: false, - status: "sent", - }, - { - id: "m2c", - author: "me", - text: "Let me know once it lands.", - time: "14:24", - delivered: true, - read: false, - status: "delivered", - }, - { - id: "m2d", - author: "me", - text: "Seen it?", - time: "14:24", - delivered: true, - read: true, - status: "read", - }, - { - id: "m3", - author: "them", - text: "Messages stay end‑to‑end encrypted here.", - time: "14:25", - delivered: true, - read: false, - }, - ], - "2": [ - { - id: "m4", - author: "them", - text: "New governance draft is live.", - time: "09:02", - delivered: true, - read: true, - }, - ], - "3": [ - { - id: "m5", - author: "me", - text: "Let’s catch up on the drop.", - time: "17:40", - delivered: true, - read: true, - }, - ], - }), - [], - ); - // Listen for new room creation - useEffect(() => { - const handleRoomCreated = (e: any) => { - const newRoom = e.detail - setChats(prev => [newRoom, ...prev]) - setSelectedChatId(newRoom.id) - } - window.addEventListener("roomCreated", handleRoomCreated) - return () => window.removeEventListener("roomCreated", handleRoomCreated) - }, []) + setSelectedChatId(id); + await markRoomRead(id); + setChats((prev) => + prev.map((c) => (c.id === id ? { ...c, unreadCount: 0 } : c)), + ); + }; const handleSendMessage = () => { - if (!inputMessage.trim() || !selectedChatId) return - + if (!inputMessage.trim() || !selectedChatId) return; const newMessage: ChatMessage = { id: `m${Date.now()}`, author: "me", text: inputMessage, - time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }), + time: new Date().toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }), delivered: false, read: false, - status: "sent" - } - - setMessagesByChat(prev => ({ + status: "sent", + }; + setMessagesByChat((prev) => ({ ...prev, - [selectedChatId]: [...(prev[selectedChatId] || []), newMessage] - })) - - setChats(prev => prev.map(chat => - chat.id === selectedChatId - ? { ...chat, lastMessage: inputMessage, lastSeen: "Just now", unreadCount: 0 } - : chat - )) - - setInputMessage("") - trackActivity(currentPublicKey, 'message') - } + [selectedChatId]: [...(prev[selectedChatId] || []), newMessage], + })); + setChats((prev) => + prev.map((chat) => + chat.id === selectedChatId + ? { + ...chat, + lastMessage: inputMessage, + lastSeen: "Just now", + unreadCount: 0, + } + : chat, + ), + ); + setInputMessage(""); + trackActivity(currentPublicKey, "message"); + }; const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault() - handleSendMessage() + e.preventDefault(); + handleSendMessage(); } - } + }; const getDeliveryStatus = (message: ChatMessage) => { if (message.status) return message.status; @@ -430,14 +300,10 @@ export default function ChatPage() { return (
-
{/* Sidebar */} - {/* Main chat area */} -
- {!selectedChat ? (
- {/* Empty state when no chat selected */} {!selectedChat && (
-

- Open a chat to get started -

-

- Everything stays end‑to‑end encrypted. -

Open a chat to get started

- Just like WhatsApp on desktop, your conversations appear - here once you pick a room from the left. Everything stays - end‑to‑end encrypted. + Your conversations appear here once you pick a room from the + left. Everything stays end-to-end encrypted.

-
- ) : ( )} - {/* Conversation view */} {selectedChat && ( <> - {/* Header */} -
- {/* Header with name + address */} + {/* Chat header */}
@@ -654,30 +489,15 @@ export default function ChatPage() {
- - - - {walletConnected && ( -
- {CONFIG.EXPERIMENTAL_REPUTATION_ENABLED && ( -
- - {reputationScore} Rep -
- )} -
- - Wallet linked + {walletConnected && + CONFIG.EXPERIMENTAL_REPUTATION_ENABLED && ( +
+ + + {reputationScore} Rep +
- -
- )} + )} @@ -692,7 +512,7 @@ export default function ChatPage() { @@ -718,10 +538,8 @@ export default function ChatPage() { className={cn( "max-w-[70%] rounded-2xl px-4 py-2.5 text-sm flex flex-col gap-1", isMine - ? "bg-[#282834] rounded-br-md" - : "bg-[#181822] border border-border/40 rounded-bl-md", ? "bg-primary/10 text-foreground rounded-br-md" - : "bg-card text-foreground rounded-bl-md", + : "bg-card text-foreground rounded-bl-md border border-border/40", )} > @@ -760,73 +578,50 @@ export default function ChatPage() {
); })} +
- {/* ENHANCED COMPOSER SECTION */} -
- {/* Branded Accessibility Label */} + {/* Composer */} +
-
- -
setMessageText(e.target.value)} + value={inputMessage} + onChange={(e) => setInputMessage(e.target.value)} + onKeyDown={handleKeyPress} placeholder="Type a message..." - className="w-full rounded-xl border border-border/60 bg-[#181822] pl-4 pr-12 py-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-[#887cc9]/40 focus:border-[#887cc9] placeholder:text-muted-foreground/50 transition-all shadow-inner" + className="w-full rounded-xl border border-border/60 bg-card pl-4 pr-12 py-3 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 placeholder:text-muted-foreground/50 transition-all" /> -
-
- {/* Composer */} -
- setInputMessage(e.target.value)} - onKeyDown={handleKeyPress} - placeholder="Type a message" - className="flex-1 rounded-full border border-border/60 bg-card px-4 py-2.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/60 placeholder:text-muted-foreground/70" - /> -
)}
-
- +
); - - ) } diff --git a/app/layout.tsx b/app/layout.tsx index 6270d91..886cc76 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,14 +1,14 @@ -import type React from "react" -import type { Metadata } from "next" -import { Geist, Geist_Mono } from "next/font/google" -import { Analytics } from "@vercel/analytics/next" -import { ThemeProvider } from "@/components/theme-provider" +import type React from "react"; +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { Analytics } from "@vercel/analytics/next"; +import { ThemeProvider } from "@/components/theme-provider"; // installed the proper toast module -import { Toaster } from "react-hot-toast" -import "./globals.css" +import { Toaster } from "react-hot-toast"; +import "./globals.css"; -const _geist = Geist({ subsets: ["latin"] }) -const _geistMono = Geist_Mono({ subsets: ["latin"] }) +const _geist = Geist({ subsets: ["latin"] }); +const _geistMono = Geist_Mono({ subsets: ["latin"] }); export const metadata: Metadata = { title: "AnonChat - Anonymous Social Network", @@ -23,16 +23,16 @@ export const metadata: Metadata = { }, ], }, -} +}; export default function RootLayout({ children, }: Readonly<{ - children: React.ReactNode + children: React.ReactNode; }>) { return ( - + - + {/* toast position */} - + - ) + ); } diff --git a/app/stellar-wallet-kit.tsx b/app/stellar-wallet-kit.tsx index 1f2e216..9d3ba8c 100644 --- a/app/stellar-wallet-kit.tsx +++ b/app/stellar-wallet-kit.tsx @@ -1,8 +1,12 @@ import { - allowAllModules, FREIGHTER_ID, StellarWalletsKit, - WalletNetwork, + RabetNetwork, + FreighterModule, + AlbedoModule, + RabetModule, + LobstrModule, + HanaModule, } from "@creit.tech/stellar-wallets-kit"; const SELECTED_WALLET_ID = "selectedWalletId"; @@ -27,41 +31,47 @@ function clearWalletStorage() { let kit: StellarWalletsKit | null = null; -function getKit(): StellarWalletsKit { +function getKit(): StellarWalletsKit | null { + if (typeof window === "undefined") return null; if (kit) return kit; - if (typeof window === "undefined") { - // Return a proxy or dummy object for SSR if needed, - // but here we just ensure functions check for window. - throw new Error("StellarWalletsKit should only be used in the browser"); + try { + kit = new StellarWalletsKit({ + modules: [ + new FreighterModule(), + new AlbedoModule(), + new RabetModule(), + new LobstrModule(), + new HanaModule(), + ], + network: RabetNetwork.PUBLIC, + selectedWalletId: getSelectedWalletId() ?? FREIGHTER_ID, + }); + } catch (e) { + console.error("Failed to initialize StellarWalletsKit:", e); + return null; } - kit = new StellarWalletsKit({ - modules: allowAllModules(), - network: WalletNetwork.PUBLIC, - selectedWalletId: getSelectedWalletId() ?? FREIGHTER_ID, - }); - return kit; } export async function signTransaction(...args: any[]) { const kitInstance = getKit(); + if (!kitInstance) return null; // @ts-ignore return kitInstance.signTransaction(...args); } -/** - * Signs an arbitrary message (nonce) with the connected Stellar wallet. - * Returns the Ed25519 signature as a lowercase hex string. - */ export async function signMessage(message: string): Promise { const kitInstance = getKit(); - const messageBytes = new TextEncoder().encode(message); - // @ts-ignore — signMessage is available in @creit.tech/stellar-wallets-kit ≥ 1.4 - const { signedMessage } = await kitInstance.signMessage(messageBytes); - // signedMessage is a Uint8Array — convert to hex for transport - return Array.from(signedMessage as unknown as Uint8Array) + if (!kitInstance) return ""; + + const { signedMessage } = await kitInstance.signMessage(message); + + // signedMessage is base64 string → convert to hex + const decoded = Uint8Array.from(atob(signedMessage), (c) => c.charCodeAt(0)); + + return Array.from(decoded) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } @@ -69,7 +79,10 @@ export async function signMessage(message: string): Promise { export async function getPublicKey() { if (typeof window === "undefined") return null; if (!getSelectedWalletId() || !isWalletConnected()) return null; + const kitInstance = getKit(); + if (!kitInstance) return null; + try { const { address } = await kitInstance.getAddress(); return address; @@ -94,7 +107,10 @@ export async function setWallet(walletId: string) { if (typeof window !== "undefined") { localStorage.setItem(SELECTED_WALLET_ID, walletId); localStorage.setItem(WALLET_CONNECTED, "true"); + const kitInstance = getKit(); + if (!kitInstance) return; + kitInstance.setWallet(walletId); } } @@ -109,10 +125,12 @@ export function onDisconnect(callback: () => void) { export async function disconnect(callback?: () => Promise) { if (typeof window !== "undefined") { clearWalletStorage(); + const kitInstance = getKit(); + if (!kitInstance) return; + kitInstance.disconnect(); - // Notify all listeners disconnectListeners.forEach((listener) => listener()); if (callback) await callback(); @@ -121,7 +139,10 @@ export async function disconnect(callback?: () => Promise) { export async function connect(callback?: () => Promise) { if (typeof window === "undefined") return; + const kitInstance = getKit(); + if (!kitInstance) return; + await kitInstance.openModal({ onWalletSelected: async (option: any) => { try { @@ -130,6 +151,7 @@ export async function connect(callback?: () => Promise) { } catch (e) { console.error(e); } + return option.id; }, }); diff --git a/package-lock.json b/package-lock.json index be714e0..98cb257 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,10 +69,16 @@ "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", + "@types/ws": "^8.18.1", + "concurrently": "^9.2.1", "postcss": "^8.5", "tailwindcss": "^4.1.9", "tw-animate-css": "1.3.3", - "typescript": "^5" + "typescript": "^5", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=20.9.0" } }, "node_modules/@albedo-link/intent": { @@ -6722,6 +6728,143 @@ "node": ">=20" } }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/concurrently/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/concurrently/node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/concurrently/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/concurrently/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/concurrently/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -7728,6 +7871,16 @@ "uncrypto": "^0.1.3" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -9926,6 +10079,19 @@ "@img/sharp-win32-x64": "0.34.5" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -10137,6 +10303,22 @@ "node": ">=14.0.0" } }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/tailwind-merge": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", @@ -10255,6 +10437,16 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-mixer": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", diff --git a/src/components/ChatWindow.tsx b/src/components/ChatWindow.tsx new file mode 100644 index 0000000..74498bf --- /dev/null +++ b/src/components/ChatWindow.tsx @@ -0,0 +1,33 @@ +import React, { useCallback } from 'react'; +import { MessageList } from './MessageList'; +import { MessageInput } from './MessageInput'; +import { useMessages } from '../hooks/useMessages'; +import { useChatSubscription } from '../hooks/useChatSubscription'; + +interface Props { + walletAddress: string; + sdk: any; + onSendToChain?: (text: string) => Promise; +} + +export const ChatWindow: React.FC = ({ walletAddress, sdk, onSendToChain }) => { + const { messages, addMessage } = useMessages(); + + useChatSubscription(sdk, addMessage); + + const handleSend = useCallback(async (text: string) => { + addMessage({ text, sender: walletAddress, isOwn: true }); + try { + await onSendToChain?.(text); + } catch (err) { + console.error('Failed to send message to chain:', err); + } + }, [addMessage, walletAddress, onSendToChain]); + + return ( +
+ + +
+ ); +}; diff --git a/src/components/MessageInput.tsx b/src/components/MessageInput.tsx new file mode 100644 index 0000000..2ac83cd --- /dev/null +++ b/src/components/MessageInput.tsx @@ -0,0 +1,33 @@ +import React, { useState, useRef, KeyboardEvent } from 'react'; + +interface Props { + onSend: (text: string) => void; + disabled?: boolean; +} + +export const MessageInput: React.FC = ({ onSend, disabled }) => { + const [text, setText] = useState(''); + const inputRef = useRef(null); + + const handleSend = () => { + const trimmed = text.trim(); + if (!trimmed) return; + onSend(trimmed); + setText(''); + inputRef.current?.focus(); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+