Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions src/components/GroupChat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
"use client";

import { useState, useEffect, useRef, useCallback } from "react";
import { shortenAddress } from "@sorosave/sdk";

interface Message {
id: string;
sender: string;
content: string;
timestamp: number;
signature?: string;
}

interface GroupChatProps {
groupId: number;
members: string[];
currentUser: string;
}

// Simple in-memory message store (replace with Gun.js or backend in production)
const messageStore: Record<number, Message[]> = {};
const listeners: Record<number, ((msgs: Message[]) => void)[]> = {};

const notifyListeners = (groupId: number) => {
if (listeners[groupId]) {
listeners[groupId].forEach((cb) => cb(messageStore[groupId] || []));
}
};

export function GroupChat({ groupId, members, currentUser }: GroupChatProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);

// Subscribe to messages
useEffect(() => {
// Initialize store for this group
if (!messageStore[groupId]) {
messageStore[groupId] = [];
}

// Add listener
if (!listeners[groupId]) {
listeners[groupId] = [];
}
const listener = (msgs: Message[]) => setMessages([...msgs]);
listeners[groupId].push(listener);

// Initial sync
setMessages([...messageStore[groupId]]);

return () => {
listeners[groupId] = listeners[groupId].filter((l) => l !== listener);
};
}, [groupId]);

// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);

// Polling for cross-tab sync (simulating real-time)
useEffect(() => {
const interval = setInterval(() => {
const stored = messageStore[groupId] || [];
if (stored.length !== messages.length) {
setMessages([...stored]);
}
}, 2000);
return () => clearInterval(interval);
}, [groupId, messages.length]);

const handleSend = useCallback(async () => {
if (!inputValue.trim() || !currentUser) return;

setIsLoading(true);

try {
// Create message with mock signature (in production, sign with wallet)
const newMessage: Message = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
sender: currentUser,
content: inputValue.trim(),
timestamp: Date.now(),
signature: `sig_${Math.random().toString(36).substr(2, 16)}`,
};

// Add to store
if (!messageStore[groupId]) {
messageStore[groupId] = [];
}
messageStore[groupId].push(newMessage);

// Notify all listeners
notifyListeners(groupId);

// Clear input
setInputValue("");
inputRef.current?.focus();
} catch (error) {
console.error("Failed to send message:", error);
} finally {
setIsLoading(false);
}
}, [inputValue, currentUser, groupId]);

const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};

const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
};

const isCurrentUser = (sender: string) =>
sender.toLowerCase() === currentUser.toLowerCase();

return (
<div className="bg-white rounded-xl shadow-sm border flex flex-col h-[500px]">
{/* Header */}
<div className="px-4 py-3 border-b flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Group Chat</h3>
<span className="text-sm text-gray-500">{members.length} members</span>
</div>

{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length === 0 ? (
<div className="text-center text-gray-400 py-8">
<p>No messages yet</p>
<p className="text-sm mt-1">Start the conversation!</p>
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`flex ${
isCurrentUser(msg.sender) ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[80%] rounded-lg px-4 py-2 ${
isCurrentUser(msg.sender)
? "bg-primary-600 text-white"
: "bg-gray-100 text-gray-900"
}`}
>
{!isCurrentUser(msg.sender) && (
<p className="text-xs font-medium opacity-75 mb-1">
{shortenAddress(msg.sender)}
</p>
)}
<p className="text-sm break-words">{msg.content}</p>
<p
className={`text-xs mt-1 ${
isCurrentUser(msg.sender)
? "text-primary-100"
: "text-gray-500"
}`}
>
{formatTime(msg.timestamp)}
</p>
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>

{/* Input */}
<div className="p-4 border-t">
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder={
currentUser ? "Type a message..." : "Connect wallet to chat"
}
disabled={!currentUser || isLoading}
className="flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
/>
<button
onClick={handleSend}
disabled={!inputValue.trim() || !currentUser || isLoading}
className="px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? (
<span className="animate-spin">⏳</span>
) : (
"Send"
)}
</button>
</div>
{currentUser && (
<p className="text-xs text-gray-500 mt-2">
Sending as {shortenAddress(currentUser)}
</p>
)}
</div>
</div>
);
}
61 changes: 61 additions & 0 deletions src/components/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"use client";

import React, { createContext, useContext, useState, useEffect, useCallback } from "react";

type Theme = "light" | "dark";

interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
}

const ThemeContext = createContext<ThemeContextType>({
theme: "light",
toggleTheme: () => {},
setTheme: () => {},
});

export function useTheme() {
return useContext(ThemeContext);
}

export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>("light");
const [mounted, setMounted] = useState(false);

useEffect(() => {
setMounted(true);
const saved = localStorage.getItem("theme") as Theme;
if (saved === "dark" || saved === "light") {
setThemeState(saved);
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
setThemeState("dark");
}
}, []);

useEffect(() => {
if (!mounted) return;
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
localStorage.setItem("theme", theme);
}, [theme, mounted]);

const toggleTheme = useCallback(() => {
setThemeState((prev) => (prev === "light" ? "dark" : "light"));
}, []);

const setTheme = useCallback((newTheme: Theme) => {
setThemeState(newTheme);
}, []);

return (
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
35 changes: 35 additions & 0 deletions src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"use client";

import { useTheme } from "./ThemeProvider";

export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();

return (
<button
onClick={toggleTheme}
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
aria-label={theme === "light" ? "Switch to dark mode" : "Switch to light mode"}
>
{theme === "light" ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
)}
</button>
);
}