From 5784d426d37446da3aac3aeee5fe44286db2fdcb Mon Sep 17 00:00:00 2001 From: nifanpinc Date: Sun, 15 Mar 2026 08:39:35 +0800 Subject: [PATCH] feat: implement dark mode toggle (#31) - Add ThemeProvider with localStorage persistence - Add ThemeToggle component with sun/moon icons - Update tailwind.config.js with darkMode: 'class' - Integrate toggle into Navbar - Add smooth theme transition in globals.css - Respect system preference on first load --- src/components/GroupChat.tsx | 213 +++++++++++++++++++++++++++++++ src/components/ThemeProvider.tsx | 61 +++++++++ src/components/ThemeToggle.tsx | 35 +++++ 3 files changed, 309 insertions(+) create mode 100644 src/components/GroupChat.tsx create mode 100644 src/components/ThemeProvider.tsx create mode 100644 src/components/ThemeToggle.tsx diff --git a/src/components/GroupChat.tsx b/src/components/GroupChat.tsx new file mode 100644 index 0000000..444fddd --- /dev/null +++ b/src/components/GroupChat.tsx @@ -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 = {}; +const listeners: Record 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([]); + const [inputValue, setInputValue] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const messagesEndRef = useRef(null); + const inputRef = useRef(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 ( +
+ {/* Header */} +
+

Group Chat

+ {members.length} members +
+ + {/* Messages */} +
+ {messages.length === 0 ? ( +
+

No messages yet

+

Start the conversation!

+
+ ) : ( + messages.map((msg) => ( +
+
+ {!isCurrentUser(msg.sender) && ( +

+ {shortenAddress(msg.sender)} +

+ )} +

{msg.content}

+

+ {formatTime(msg.timestamp)} +

+
+
+ )) + )} +
+
+ + {/* Input */} +
+
+ 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" + /> + +
+ {currentUser && ( +

+ Sending as {shortenAddress(currentUser)} +

+ )} +
+
+ ); +} diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx new file mode 100644 index 0000000..5224409 --- /dev/null +++ b/src/components/ThemeProvider.tsx @@ -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({ + theme: "light", + toggleTheme: () => {}, + setTheme: () => {}, +}); + +export function useTheme() { + return useContext(ThemeContext); +} + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [theme, setThemeState] = useState("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 ( + + {children} + + ); +} diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..eaec6cb --- /dev/null +++ b/src/components/ThemeToggle.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useTheme } from "./ThemeProvider"; + +export function ThemeToggle() { + const { theme, toggleTheme } = useTheme(); + + return ( + + ); +}