├── App.svelte ├── README.md ├── app ├── components │ ├── ConversationView.tsx │ ├── DebugSync.tsx │ ├── EnhancedLandingPage.tsx │ ├── GroupChat.tsx │ ├── MainApp.tsx │ ├── MessageTest.tsx │ ├── PinataSettings.tsx │ ├── SettingsView.tsx │ ├── StorageSelector.tsx │ ├── WalletProvider.tsx │ └── ui │ │ ├── skeleton.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.tsx ├── globals.css ├── layout.tsx ├── lib │ ├── arweave-real.ts │ ├── arweave.ts │ ├── contacts.ts │ ├── conversation-manager.ts │ ├── encryption.ts │ ├── groups.ts │ ├── ipfs-message-sync.ts │ ├── ipfs-messaging.ts │ ├── ipfs.ts │ ├── messages.ts │ ├── notifications.ts │ ├── offline-sync.ts │ ├── search.ts │ ├── solana.ts │ ├── spam-protection.ts │ └── storage-manager.ts └── page.tsx ├── components.json ├── components ├── BurnerWallet.svelte ├── ChatInterface.svelte ├── ContactList.svelte ├── MessageBubble.svelte ├── Settings.svelte ├── WalletConnect.svelte ├── theme-provider.tsx └── ui │ ├── accordion.tsx │ ├── alert-dialog.tsx │ ├── alert.tsx │ ├── aspect-ratio.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── breadcrumb.tsx │ ├── button.tsx │ ├── calendar.tsx │ ├── card.tsx │ ├── carousel.tsx │ ├── chart.tsx │ ├── checkbox.tsx │ ├── collapsible.tsx │ ├── command.tsx │ ├── context-menu.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── hover-card.tsx │ ├── input-otp.tsx │ ├── input.tsx │ ├── label.tsx │ ├── menubar.tsx │ ├── navigation-menu.tsx │ ├── pagination.tsx │ ├── popover.tsx │ ├── progress.tsx │ ├── radio-group.tsx │ ├── resizable.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── sidebar.tsx │ ├── skeleton.tsx │ ├── slider.tsx │ ├── sonner.tsx │ ├── switch.tsx │ ├── table.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ ├── toast.tsx │ ├── toaster.tsx │ ├── toggle-group.tsx │ ├── toggle.tsx │ ├── tooltip.tsx │ ├── use-mobile.tsx │ └── use-toast.ts ├── hooks ├── use-mobile.tsx └── use-toast.ts ├── lib ├── app.js ├── burner.js ├── encryption.js ├── messaging.js ├── storage.js ├── stores.js ├── utils.js ├── utils.ts └── wallet.js ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── manifest.json ├── placeholder-logo.png ├── placeholder-logo.svg ├── placeholder-user.jpg ├── placeholder.jpg └── placeholder.svg ├── styles └── globals.css ├── tailwind.config.ts └── tsconfig.json /README.md: -------------------------------------------------------------------------------- 1 | 2 | # SolChat ✨ 3 | 4 | [![MIT License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) 5 | [![Last Commit](https://img.shields.io/github/last-commit/solchat-dev/solchat.svg)](https://github.com/solchat-dev/solchat/commits/main) 6 | [![Repo Size](https://img.shields.io/github/repo-size/solchat-dev/solchat)](https://github.com/solchat-dev/solchat) 7 | [![Stars](https://img.shields.io/github/stars/solchat-dev/solchat?style=social)](https://github.com/solchat-dev/solchat/stargazers) 8 | ![TypeScript](https://img.shields.io/badge/TypeScript-88.1%25-blue?logo=typescript) 9 | ![Svelte](https://img.shields.io/badge/Svelte-8.6%25-orange?logo=svelte) 10 | ![JavaScript](https://img.shields.io/badge/JavaScript-2.5%25-yellow?logo=javascript) 11 | ![CSS](https://img.shields.io/badge/CSS-0.8%25-blueviolet?logo=css3) 12 | 13 | **SolChat** is a secure, decentralized peer-to-peer messaging application built on the Solana blockchain. It features end-to-end encryption, decentralized file sharing, and a modern UI, providing a private and robust communication experience. 14 | 15 | --- 16 | 17 | ## 🚀 Features 18 | 19 | ### 🔐 Secure Messaging 20 | - End-to-end encrypted messages using TweetNaCl. 21 | - Messages stored locally with optional IPFS integration. 22 | - Seamless wallet-based authentication (Phantom, Solflare, Backpack). 23 | 24 | ### 👥 User Management 25 | - Custom usernames and editable profiles. 26 | - Recent peers list with real-time status indicators. 27 | - Nickname support for easier peer identification. 28 | 29 | ### 📁 File Sharing 30 | - Share images, PDFs, and other documents securely. 31 | - Built-in image preview modal. 32 | - IPFS-backed decentralized file storage. 33 | 34 | ### 💻 UI & UX 35 | - Responsive design for mobile and desktop. 36 | - Real-time message status updates. 37 | - Intuitive peer selection and chat interface. 38 | 39 | --- 40 | 41 | ## 🛠️ Prerequisites 42 | 43 | - **Node.js** v18+ 44 | - **npm** or **Yarn** 45 | - **Git** (recommended) 46 | - A **Solana Wallet** (e.g., Phantom browser extension) 47 | 48 | --- 49 | 50 | ## 📦 Installation 51 | 52 | ```bash 53 | git clone https://github.com/solchat-dev/solchat.git 54 | cd solchat 55 | npm install 56 | # or 57 | yarn install 58 | ``` 59 | 60 | ### Configuration 61 | 62 | - Modify the RPC endpoint and cluster config in `src/index.ts` (or equivalent). 63 | - Ensure the correct on-chain chat program address is set in `src/idls/solchat.json`: 64 | ```json 65 | "metadata": { 66 | "address": "", 67 | "origin": "anchor" 68 | } 69 | ``` 70 | 71 | ### Generate TypeScript Types 72 | 73 | ```bash 74 | npm install -g typescript ts-node 75 | ts-node generate.ts 76 | ``` 77 | 78 | --- 79 | 80 | ## 🚀 Usage 81 | 82 | Start the development server: 83 | 84 | ```bash 85 | npm start 86 | # or 87 | yarn dev 88 | ``` 89 | 90 | 1. Connect your Solana wallet via the "Select Wallet" button. 91 | 2. Set your username when prompted. 92 | 3. Start messaging by entering a Solana address or selecting a recent peer. 93 | 4. Attach files via the paperclip icon. Image previews are supported. 94 | 5. Manage contacts and nicknames from the peers panel. 95 | 96 | --- 97 | 98 | ## ⚙️ Tech Stack 99 | 100 | ### Frontend 101 | - React 18 + TypeScript 102 | - Tailwind CSS 103 | - Vite 104 | 105 | ### Blockchain 106 | - `@solana/web3.js` 107 | - `@solana/wallet-adapter` 108 | 109 | ### Storage & Encryption 110 | - IPFS / Helia 111 | - TweetNaCl 112 | - LocalStorage 113 | 114 | --- 115 | 116 | ## 📜 License 117 | 118 | Licensed under the [MIT License](LICENSE). 119 | 120 | --- 121 | 122 | Made with ❤️ by the SolChat team. 123 | -------------------------------------------------------------------------------- /app/components/DebugSync.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { useToast } from "@/components/ui/use-toast" 5 | import { useWallet } from "@solana/wallet-adapter-react" 6 | import { ContactService } from "@/app/lib/contacts" 7 | 8 | export function DebugSync() { 9 | const { publicKey } = useWallet() 10 | const { toast } = useToast() 11 | 12 | return ( 13 |
14 |

Debug

15 | 16 | {/* Contact Debug Section */} 17 |
18 |

Contact Debug

19 | 20 |
21 | 50 | 51 | 87 |
88 |
89 |
90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /app/components/MessageTest.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | import { Button } from "@/components/ui/button" 5 | import { Input } from "@/components/ui/input" 6 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 7 | import { Badge } from "@/components/ui/badge" 8 | import { MessageService } from "../lib/messages" 9 | import { IPFSMessagingService } from "../lib/ipfs-messaging" 10 | 11 | export function MessageTest() { 12 | const [testWallet] = useState("test_wallet_123") 13 | const [testContact] = useState("test_contact_456") 14 | const [messageText, setMessageText] = useState("") 15 | const [messages, setMessages] = useState([]) 16 | const [isLoading, setIsLoading] = useState(false) 17 | const [ipfsStats, setIpfsStats] = useState(null) 18 | 19 | const messageService = new MessageService() 20 | const ipfsService = new IPFSMessagingService() 21 | 22 | const sendTestMessage = async () => { 23 | if (!messageText.trim()) return 24 | 25 | setIsLoading(true) 26 | try { 27 | const message = { 28 | id: `test_${Date.now()}`, 29 | from: testWallet, 30 | to: testContact, 31 | content: messageText, 32 | timestamp: Date.now(), 33 | messageType: "text" as const, 34 | status: "sent" as const, 35 | } 36 | 37 | // Save locally first 38 | await messageService.saveSentMessage(testWallet, message) 39 | 40 | // Load updated messages 41 | const updatedMessages = await messageService.getConversationMessages(testWallet, testContact) 42 | setMessages(updatedMessages) 43 | 44 | setMessageText("") 45 | console.log("✅ Test message sent successfully") 46 | } catch (error) { 47 | console.error("❌ Failed to send test message:", error) 48 | } finally { 49 | setIsLoading(false) 50 | } 51 | } 52 | 53 | const loadMessages = async () => { 54 | setIsLoading(true) 55 | try { 56 | const conversationMessages = await messageService.getConversationMessages(testWallet, testContact) 57 | setMessages(conversationMessages) 58 | console.log(`📚 Loaded ${conversationMessages.length} messages`) 59 | } catch (error) { 60 | console.error("❌ Failed to load messages:", error) 61 | } finally { 62 | setIsLoading(false) 63 | } 64 | } 65 | 66 | const checkIPFSStats = async () => { 67 | try { 68 | const stats = await ipfsService.getStats() 69 | setIpfsStats(stats) 70 | console.log("📊 IPFS Stats:", stats) 71 | } catch (error) { 72 | console.error("❌ Failed to get IPFS stats:", error) 73 | } 74 | } 75 | 76 | const clearTestData = async () => { 77 | try { 78 | await messageService.clearAllMessages(testWallet) 79 | setMessages([]) 80 | console.log("🗑️ Cleared test data") 81 | } catch (error) { 82 | console.error("❌ Failed to clear test data:", error) 83 | } 84 | } 85 | 86 | return ( 87 |
88 | 89 | 90 | Message System Test 91 | 92 | 93 |
94 | setMessageText(e.target.value)} 98 | className="bg-gray-800 border-gray-600 text-white" 99 | onKeyDown={(e) => { 100 | if (e.key === "Enter") { 101 | sendTestMessage() 102 | } 103 | }} 104 | /> 105 | 108 |
109 | 110 |
111 | 114 | 117 | 120 |
121 | 122 | {ipfsStats && ( 123 |
124 |
125 |
IPFS Configured
126 |
127 | {ipfsStats.isConfigured ? ( 128 | ✅ Yes 129 | ) : ( 130 | ❌ No 131 | )} 132 |
133 |
134 |
135 |
Message Pointers
136 |
{ipfsStats.totalPointers}
137 |
138 |
139 | )} 140 | 141 |
142 |

Messages ({messages.length})

143 |
144 | {messages.map((message) => ( 145 |
151 |
{message.content}
152 |
153 | {new Date(message.timestamp).toLocaleTimeString()} 154 | {message.status && {message.status}} 155 |
156 |
157 | ))} 158 |
159 |
160 |
161 |
162 |
163 | ) 164 | } 165 | -------------------------------------------------------------------------------- /app/components/WalletProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type React from "react" 4 | import { useMemo } from "react" 5 | import { ConnectionProvider, WalletProvider as SolanaWalletProvider } from "@solana/wallet-adapter-react" 6 | import { WalletAdapterNetwork } from "@solana/wallet-adapter-base" 7 | import { 8 | PhantomWalletAdapter, 9 | SolflareWalletAdapter, 10 | TorusWalletAdapter, 11 | LedgerWalletAdapter, 12 | } from "@solana/wallet-adapter-wallets" 13 | import { WalletModalProvider } from "@solana/wallet-adapter-react-ui" 14 | 15 | // Import wallet adapter CSS 16 | import "@solana/wallet-adapter-react-ui/styles.css" 17 | 18 | export function WalletProvider({ children }: { children: React.ReactNode }) { 19 | // Use mainnet for production 20 | const network = WalletAdapterNetwork.Mainnet 21 | 22 | // Use multiple RPC endpoints with fallback 23 | const endpoint = useMemo(() => { 24 | const endpoints = [ 25 | "https://rpc.ankr.com/solana", 26 | "https://solana.public-rpc.com", 27 | "https://api.mainnet-beta.solana.com", 28 | "https://solana-api.projectserum.com", 29 | ] 30 | 31 | // For now, use the first one. In a real app, you'd implement endpoint rotation 32 | return endpoints[0] 33 | }, []) 34 | 35 | // Initialize supported wallets 36 | const wallets = useMemo( 37 | () => [ 38 | new PhantomWalletAdapter(), 39 | new SolflareWalletAdapter(), 40 | new TorusWalletAdapter(), 41 | new LedgerWalletAdapter(), 42 | ], 43 | [], 44 | ) 45 | 46 | return ( 47 | 48 | 49 | {children} 50 | 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /app/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cn } from "@/lib/utils" 3 | 4 | function Skeleton({ className, ...props }: React.HTMLAttributes) { 5 | return
6 | } 7 | 8 | export { Skeleton } 9 | -------------------------------------------------------------------------------- /app/components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Cross2Icon } from "@radix-ui/react-icons" 5 | import * as ToastPrimitives from "@radix-ui/react-toast" 6 | import { cva, type VariantProps } from "class-variance-authority" 7 | 8 | import { cn } from "@/lib/utils" 9 | 10 | const ToastProvider = ToastPrimitives.Provider 11 | 12 | const ToastViewport = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, ...props }, ref) => ( 16 | 24 | )) 25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName 26 | 27 | const toastVariants = cva( 28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", 29 | { 30 | variants: { 31 | variant: { 32 | default: "border bg-background text-foreground", 33 | destructive: "destructive border-destructive bg-destructive text-destructive-foreground", 34 | success: "border-green-500 bg-green-500 text-white", 35 | }, 36 | }, 37 | defaultVariants: { 38 | variant: "default", 39 | }, 40 | }, 41 | ) 42 | 43 | const Toast = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef & VariantProps 46 | >(({ className, variant, ...props }, ref) => { 47 | return 48 | }) 49 | Toast.displayName = ToastPrimitives.Root.displayName 50 | 51 | const ToastAction = React.forwardRef< 52 | React.ElementRef, 53 | React.ComponentPropsWithoutRef 54 | >(({ className, ...props }, ref) => ( 55 | 63 | )) 64 | ToastAction.displayName = ToastPrimitives.Action.displayName 65 | 66 | const ToastClose = React.forwardRef< 67 | React.ElementRef, 68 | React.ComponentPropsWithoutRef 69 | >(({ className, ...props }, ref) => ( 70 | 79 | 80 | 81 | )) 82 | ToastClose.displayName = ToastPrimitives.Close.displayName 83 | 84 | const ToastTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 89 | )) 90 | ToastTitle.displayName = ToastPrimitives.Title.displayName 91 | 92 | const ToastDescription = React.forwardRef< 93 | React.ElementRef, 94 | React.ComponentPropsWithoutRef 95 | >(({ className, ...props }, ref) => ( 96 | 97 | )) 98 | ToastDescription.displayName = ToastPrimitives.Description.displayName 99 | 100 | type ToastProps = React.ComponentPropsWithoutRef 101 | 102 | type ToastActionElement = React.ReactElement 103 | 104 | export { 105 | type ToastProps, 106 | type ToastActionElement, 107 | ToastProvider, 108 | ToastViewport, 109 | Toast, 110 | ToastTitle, 111 | ToastDescription, 112 | ToastClose, 113 | ToastAction, 114 | } 115 | -------------------------------------------------------------------------------- /app/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast" 4 | import { useToast } from "@/components/ui/use-toast" 5 | 6 | export function Toaster() { 7 | const { toasts } = useToast() 8 | 9 | return ( 10 | 11 | {toasts.map(({ id, title, description, action, ...props }) => ( 12 | 13 |
14 | {title && {title}} 15 | {description && {description}} 16 |
17 | {action} 18 | 19 |
20 | ))} 21 | 22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /app/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /app/components/ui/use-toast.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | 5 | import type { ToastActionElement, ToastProps } from "@/components/ui/toast" 6 | 7 | const TOAST_LIMIT = 1 8 | const TOAST_REMOVE_DELAY = 1000000 9 | 10 | type ToasterToast = ToastProps & { 11 | id: string 12 | title?: React.ReactNode 13 | description?: React.ReactNode 14 | action?: ToastActionElement 15 | } 16 | 17 | const actionTypes = { 18 | ADD_TOAST: "ADD_TOAST", 19 | UPDATE_TOAST: "UPDATE_TOAST", 20 | DISMISS_TOAST: "DISMISS_TOAST", 21 | REMOVE_TOAST: "REMOVE_TOAST", 22 | } as const 23 | 24 | let count = 0 25 | 26 | function genId() { 27 | count = (count + 1) % Number.MAX_SAFE_INTEGER 28 | return count.toString() 29 | } 30 | 31 | type ActionType = typeof actionTypes 32 | 33 | type Action = 34 | | { 35 | type: ActionType["ADD_TOAST"] 36 | toast: ToasterToast 37 | } 38 | | { 39 | type: ActionType["UPDATE_TOAST"] 40 | toast: Partial 41 | } 42 | | { 43 | type: ActionType["DISMISS_TOAST"] 44 | toastId?: ToasterToast["id"] 45 | } 46 | | { 47 | type: ActionType["REMOVE_TOAST"] 48 | toastId?: ToasterToast["id"] 49 | } 50 | 51 | interface State { 52 | toasts: ToasterToast[] 53 | } 54 | 55 | const toastTimeouts = new Map>() 56 | 57 | const addToRemoveQueue = (toastId: string) => { 58 | if (toastTimeouts.has(toastId)) { 59 | return 60 | } 61 | 62 | const timeout = setTimeout(() => { 63 | toastTimeouts.delete(toastId) 64 | dispatch({ 65 | type: "REMOVE_TOAST", 66 | toastId: toastId, 67 | }) 68 | }, TOAST_REMOVE_DELAY) 69 | 70 | toastTimeouts.set(toastId, timeout) 71 | } 72 | 73 | export const reducer = (state: State, action: Action): State => { 74 | switch (action.type) { 75 | case "ADD_TOAST": 76 | return { 77 | ...state, 78 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 79 | } 80 | 81 | case "UPDATE_TOAST": 82 | return { 83 | ...state, 84 | toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)), 85 | } 86 | 87 | case "DISMISS_TOAST": { 88 | const { toastId } = action 89 | 90 | if (toastId) { 91 | addToRemoveQueue(toastId) 92 | } else { 93 | state.toasts.forEach((toast) => { 94 | addToRemoveQueue(toast.id) 95 | }) 96 | } 97 | 98 | return { 99 | ...state, 100 | toasts: state.toasts.map((t) => 101 | t.id === toastId || toastId === undefined 102 | ? { 103 | ...t, 104 | open: false, 105 | } 106 | : t, 107 | ), 108 | } 109 | } 110 | case "REMOVE_TOAST": 111 | if (action.toastId === undefined) { 112 | return { 113 | ...state, 114 | toasts: [], 115 | } 116 | } 117 | return { 118 | ...state, 119 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 120 | } 121 | } 122 | } 123 | 124 | const listeners: Array<(state: State) => void> = [] 125 | 126 | let memoryState: State = { toasts: [] } 127 | 128 | function dispatch(action: Action) { 129 | memoryState = reducer(memoryState, action) 130 | listeners.forEach((listener) => { 131 | listener(memoryState) 132 | }) 133 | } 134 | 135 | type Toast = Omit 136 | 137 | function toast({ ...props }: Toast) { 138 | const id = genId() 139 | 140 | const update = (props: ToasterToast) => 141 | dispatch({ 142 | type: "UPDATE_TOAST", 143 | toast: { ...props, id }, 144 | }) 145 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) 146 | 147 | dispatch({ 148 | type: "ADD_TOAST", 149 | toast: { 150 | ...props, 151 | id, 152 | open: true, 153 | onOpenChange: (open) => { 154 | if (!open) dismiss() 155 | }, 156 | }, 157 | }) 158 | 159 | return { 160 | id: id, 161 | dismiss, 162 | update, 163 | } 164 | } 165 | 166 | function useToast() { 167 | const [state, setState] = React.useState(memoryState) 168 | 169 | React.useEffect(() => { 170 | listeners.push(setState) 171 | return () => { 172 | const index = listeners.indexOf(setState) 173 | if (index > -1) { 174 | listeners.splice(index, 1) 175 | } 176 | } 177 | }, [state]) 178 | 179 | return { 180 | ...state, 181 | toast, 182 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 183 | } 184 | } 185 | 186 | export { useToast, toast } 187 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 222.2 84% 4.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 222.2 84% 4.9%; 13 | --primary: 262.1 83.3% 57.8%; 14 | --primary-foreground: 210 40% 98%; 15 | --secondary: 210 40% 96.1%; 16 | --secondary-foreground: 222.2 47.4% 11.2%; 17 | --muted: 210 40% 96.1%; 18 | --muted-foreground: 215.4 16.3% 46.9%; 19 | --accent: 210 40% 96.1%; 20 | --accent-foreground: 222.2 47.4% 11.2%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 210 40% 98%; 23 | --border: 214.3 31.8% 91.4%; 24 | --input: 214.3 31.8% 91.4%; 25 | --ring: 262.1 83.3% 57.8%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 222.2 84% 4.9%; 31 | --foreground: 210 40% 98%; 32 | --card: 222.2 84% 4.9%; 33 | --card-foreground: 210 40% 98%; 34 | --popover: 222.2 84% 4.9%; 35 | --popover-foreground: 210 40% 98%; 36 | --primary: 262.1 83.3% 57.8%; 37 | --primary-foreground: 210 40% 98%; 38 | --secondary: 217.2 32.6% 17.5%; 39 | --secondary-foreground: 210 40% 98%; 40 | --muted: 217.2 32.6% 17.5%; 41 | --muted-foreground: 215 20.2% 65.1%; 42 | --accent: 217.2 32.6% 17.5%; 43 | --accent-foreground: 210 40% 98%; 44 | --destructive: 0 62.8% 30.6%; 45 | --destructive-foreground: 210 40% 98%; 46 | --border: 217.2 32.6% 17.5%; 47 | --input: 217.2 32.6% 17.5%; 48 | --ring: 262.1 83.3% 57.8%; 49 | } 50 | } 51 | 52 | @layer base { 53 | * { 54 | @apply border-border; 55 | } 56 | body { 57 | @apply bg-background text-foreground; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react" 2 | import type { Metadata } from "next" 3 | import { Inter } from "next/font/google" 4 | import "./globals.css" 5 | import { ThemeProvider } from "@/components/theme-provider" 6 | 7 | const inter = Inter({ subsets: ["latin"] }) 8 | 9 | export const metadata: Metadata = { 10 | title: "SolChat - Decentralized Messaging", 11 | description: "A fully decentralized messaging platform with end-to-end encryption on Solana", 12 | manifest: "/manifest.json", 13 | icons: { 14 | icon: "/favicon.ico", 15 | apple: "/icon-192.png", 16 | }, 17 | themeColor: "#7c3aed", 18 | viewport: "width=device-width, initial-scale=1, maximum-scale=1", 19 | generator: 'v0.dev' 20 | } 21 | 22 | export default function RootLayout({ 23 | children, 24 | }: { 25 | children: React.ReactNode 26 | }) { 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | {children} 40 | 41 | 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /app/lib/arweave.ts: -------------------------------------------------------------------------------- 1 | export class ArweaveService { 2 | private baseUrl = "https://arweave.net" 3 | 4 | async storeMessage(message: any): Promise { 5 | try { 6 | // In a real implementation, this would use the Arweave SDK 7 | // For demo purposes, we'll simulate storage 8 | const mockTxId = `arweave_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` 9 | 10 | // Simulate network delay 11 | await new Promise((resolve) => setTimeout(resolve, 1000)) 12 | 13 | console.log("Stored message on Arweave:", mockTxId) 14 | return mockTxId 15 | } catch (error) { 16 | console.error("Failed to store on Arweave:", error) 17 | throw error 18 | } 19 | } 20 | 21 | async storeGroupMessage(message: any): Promise { 22 | try { 23 | // In a real implementation, this would use the Arweave SDK 24 | // For demo purposes, we'll simulate storage 25 | const mockTxId = `arweave_group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` 26 | 27 | // Simulate network delay 28 | await new Promise((resolve) => setTimeout(resolve, 1000)) 29 | 30 | console.log("Stored group message on Arweave:", mockTxId) 31 | return mockTxId 32 | } catch (error) { 33 | console.error("Failed to store group message on Arweave:", error) 34 | throw error 35 | } 36 | } 37 | 38 | async retrieveMessage(txId: string): Promise { 39 | try { 40 | // In a real implementation, this would fetch from Arweave 41 | // For demo purposes, we'll return null 42 | console.log("Retrieving from Arweave:", txId) 43 | return null 44 | } catch (error) { 45 | console.error("Failed to retrieve from Arweave:", error) 46 | throw error 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/lib/contacts.ts: -------------------------------------------------------------------------------- 1 | export interface Contact { 2 | address: string 3 | nickname?: string 4 | avatar?: string 5 | addedAt: number 6 | verified: boolean 7 | publicKey?: string 8 | } 9 | 10 | export class ContactService { 11 | private getStorageKey(walletAddress: string): string { 12 | return `contacts_${walletAddress}` 13 | } 14 | 15 | async getContacts(walletAddress: string): Promise { 16 | try { 17 | const stored = localStorage.getItem(this.getStorageKey(walletAddress)) 18 | return stored ? JSON.parse(stored) : [] 19 | } catch (error) { 20 | console.error("Failed to load contacts:", error) 21 | return [] 22 | } 23 | } 24 | 25 | async addContact(walletAddress: string, contact: Contact): Promise { 26 | try { 27 | console.log(`📝 ContactService.addContact called for wallet ${walletAddress}:`, contact) 28 | 29 | // Validate inputs 30 | if (!walletAddress || !contact.address) { 31 | throw new Error("Missing required parameters: walletAddress or contact.address") 32 | } 33 | 34 | // Get current contacts 35 | const contacts = await this.getContacts(walletAddress) 36 | console.log("📋 Current contacts:", contacts.length) 37 | 38 | // Check if contact already exists 39 | const existingIndex = contacts.findIndex((c) => c.address === contact.address) 40 | if (existingIndex >= 0) { 41 | console.log("🔄 Contact already exists, updating:", existingIndex) 42 | contacts[existingIndex] = { ...contacts[existingIndex], ...contact } 43 | } else { 44 | console.log("➕ Adding new contact") 45 | contacts.push(contact) 46 | } 47 | 48 | // Save to localStorage 49 | const storageKey = this.getStorageKey(walletAddress) 50 | const contactsJson = JSON.stringify(contacts) 51 | 52 | console.log("💾 Saving to storage key:", storageKey) 53 | console.log("📄 Contacts JSON length:", contactsJson.length) 54 | 55 | localStorage.setItem(storageKey, contactsJson) 56 | 57 | // Verify the save worked 58 | const savedContacts = localStorage.getItem(storageKey) 59 | if (!savedContacts) { 60 | throw new Error("Failed to save contacts - localStorage returned null") 61 | } 62 | 63 | const parsedSaved = JSON.parse(savedContacts) 64 | console.log("✅ Verified save - contacts count:", parsedSaved.length) 65 | 66 | if (parsedSaved.length !== contacts.length) { 67 | throw new Error(`Save verification failed - expected ${contacts.length}, got ${parsedSaved.length}`) 68 | } 69 | } catch (error) { 70 | console.error("❌ ContactService.addContact failed:", error) 71 | throw error 72 | } 73 | } 74 | 75 | async updateContact(walletAddress: string, contact: Contact): Promise { 76 | try { 77 | const contacts = await this.getContacts(walletAddress) 78 | const index = contacts.findIndex((c) => c.address === contact.address) 79 | 80 | if (index >= 0) { 81 | contacts[index] = contact 82 | localStorage.setItem(this.getStorageKey(walletAddress), JSON.stringify(contacts)) 83 | } 84 | } catch (error) { 85 | console.error("Failed to update contact:", error) 86 | throw error 87 | } 88 | } 89 | 90 | async removeContact(walletAddress: string, contactAddress: string): Promise { 91 | try { 92 | const contacts = await this.getContacts(walletAddress) 93 | const filtered = contacts.filter((c) => c.address !== contactAddress) 94 | localStorage.setItem(this.getStorageKey(walletAddress), JSON.stringify(filtered)) 95 | } catch (error) { 96 | console.error("Failed to remove contact:", error) 97 | throw error 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /app/lib/encryption.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from "crypto" 2 | 3 | export class EncryptionService { 4 | private async generateKeyPair() { 5 | // Generate x25519 key pair 6 | const privateKey = randomBytes(32) 7 | const publicKey = await this.derivePublicKey(privateKey) 8 | 9 | return { 10 | privateKey: Array.from(privateKey), 11 | publicKey: Array.from(publicKey), 12 | } 13 | } 14 | 15 | private async derivePublicKey(privateKey: Uint8Array): Promise { 16 | // In a real implementation, this would use actual x25519 curve operations 17 | // For demo purposes, we'll simulate it 18 | return new Uint8Array(32).map((_, i) => privateKey[i] ^ 0x42) 19 | } 20 | 21 | async generateOrLoadKeys(walletAddress: string) { 22 | const storageKey = `encryption_keys_${walletAddress}` 23 | const stored = localStorage.getItem(storageKey) 24 | 25 | if (stored) { 26 | return JSON.parse(stored) 27 | } 28 | 29 | const keys = await this.generateKeyPair() 30 | localStorage.setItem(storageKey, JSON.stringify(keys)) 31 | return keys 32 | } 33 | 34 | async encryptMessage(message: string, recipientPublicKey: string, senderPrivateKey: number[]): Promise { 35 | // In a real implementation, this would use actual x25519 + ChaCha20-Poly1305 36 | // For demo purposes, we'll use a simple XOR cipher 37 | const messageBytes = new TextEncoder().encode(message) 38 | const key = senderPrivateKey.slice(0, 32) 39 | 40 | const encrypted = messageBytes.map((byte, i) => byte ^ key[i % key.length]) 41 | return btoa(String.fromCharCode(...encrypted)) 42 | } 43 | 44 | async decryptMessage( 45 | encryptedMessage: string, 46 | senderPublicKey: string, 47 | recipientPrivateKey: number[], 48 | ): Promise { 49 | // In a real implementation, this would use actual x25519 + ChaCha20-Poly1305 50 | // For demo purposes, we'll use a simple XOR cipher 51 | const encrypted = new Uint8Array( 52 | atob(encryptedMessage) 53 | .split("") 54 | .map((c) => c.charCodeAt(0)), 55 | ) 56 | const key = recipientPrivateKey.slice(0, 32) 57 | 58 | const decrypted = encrypted.map((byte, i) => byte ^ key[i % key.length]) 59 | return new TextDecoder().decode(decrypted) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/lib/notifications.ts: -------------------------------------------------------------------------------- 1 | export class NotificationService { 2 | private static instance: NotificationService 3 | private notificationPermission: NotificationPermission = "default" 4 | 5 | static getInstance(): NotificationService { 6 | if (!NotificationService.instance) { 7 | NotificationService.instance = new NotificationService() 8 | } 9 | return NotificationService.instance 10 | } 11 | 12 | async initialize(): Promise { 13 | try { 14 | // Check if notifications are supported 15 | if (!("Notification" in window)) { 16 | console.warn("Notifications not supported") 17 | return false 18 | } 19 | 20 | this.notificationPermission = Notification.permission 21 | console.log("Notifications initialized successfully") 22 | return true 23 | } catch (error) { 24 | console.error("Failed to initialize notifications:", error) 25 | return false 26 | } 27 | } 28 | 29 | async requestPermission(): Promise { 30 | try { 31 | if (!("Notification" in window)) { 32 | return false 33 | } 34 | 35 | if (this.notificationPermission === "default") { 36 | this.notificationPermission = await Notification.requestPermission() 37 | } 38 | 39 | return this.notificationPermission === "granted" 40 | } catch (error) { 41 | console.error("Failed to request notification permission:", error) 42 | return false 43 | } 44 | } 45 | 46 | async subscribeToPush(): Promise { 47 | // For basic notifications, we don't need push subscriptions 48 | // This would be used for server-sent push notifications 49 | return this.notificationPermission === "granted" 50 | } 51 | 52 | async showLocalNotification( 53 | title: string, 54 | options: { 55 | body?: string 56 | icon?: string 57 | badge?: string 58 | tag?: string 59 | data?: any 60 | requireInteraction?: boolean 61 | } = {}, 62 | ): Promise { 63 | try { 64 | if (this.notificationPermission !== "granted") { 65 | console.warn("Notification permission not granted") 66 | return 67 | } 68 | 69 | // Check if the page is visible 70 | if (document.visibilityState === "visible") { 71 | // Don't show notification if user is actively using the app 72 | return 73 | } 74 | 75 | const notification = new Notification(title, { 76 | body: options.body, 77 | icon: options.icon || "/icon-192.png", 78 | badge: options.badge || "/icon-192.png", 79 | tag: options.tag || "solchat-message", 80 | data: options.data, 81 | requireInteraction: options.requireInteraction || false, 82 | silent: false, 83 | }) 84 | 85 | notification.onclick = () => { 86 | window.focus() 87 | notification.close() 88 | 89 | // Handle notification click 90 | if (options.data?.action) { 91 | this.handleNotificationAction(options.data.action, options.data) 92 | } 93 | } 94 | 95 | // Auto-close after 10 seconds 96 | setTimeout(() => { 97 | notification.close() 98 | }, 10000) 99 | } catch (error) { 100 | console.error("Failed to show local notification:", error) 101 | } 102 | } 103 | 104 | private handleNotificationAction(action: string, data: any): void { 105 | switch (action) { 106 | case "reply": 107 | // Focus on message input 108 | const messageInput = document.querySelector('textarea[placeholder*="message"]') as HTMLTextAreaElement 109 | if (messageInput) { 110 | messageInput.focus() 111 | } 112 | break 113 | case "view": 114 | // Scroll to message or contact 115 | if (data.contactAddress) { 116 | // Trigger contact selection 117 | window.dispatchEvent( 118 | new CustomEvent("selectContact", { 119 | detail: { address: data.contactAddress }, 120 | }), 121 | ) 122 | } 123 | break 124 | } 125 | } 126 | 127 | async notifyNewMessage(from: string, content: string, contactNickname?: string): Promise { 128 | const title = `New message from ${contactNickname || from.slice(0, 8) + "..."}` 129 | const body = content.length > 100 ? content.slice(0, 100) + "..." : content 130 | 131 | await this.showLocalNotification(title, { 132 | body, 133 | tag: `message-${from}`, 134 | requireInteraction: true, 135 | data: { 136 | action: "view", 137 | contactAddress: from, 138 | messageContent: content, 139 | }, 140 | }) 141 | } 142 | 143 | async notifyNewGroupMessage(groupName: string, from: string, content: string): Promise { 144 | const title = `New message in ${groupName}` 145 | const body = `${from.slice(0, 8)}...: ${content.length > 80 ? content.slice(0, 80) + "..." : content}` 146 | 147 | await this.showLocalNotification(title, { 148 | body, 149 | tag: `group-message-${groupName}`, 150 | requireInteraction: true, 151 | data: { 152 | action: "view", 153 | groupName, 154 | }, 155 | }) 156 | } 157 | 158 | getSubscription(): null { 159 | // Return null since we're not using push subscriptions 160 | return null 161 | } 162 | 163 | async unsubscribe(): Promise { 164 | // For basic notifications, we just return true 165 | return true 166 | } 167 | 168 | getPermissionStatus(): NotificationPermission { 169 | return this.notificationPermission 170 | } 171 | 172 | isSupported(): boolean { 173 | return "Notification" in window 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /app/lib/offline-sync.ts: -------------------------------------------------------------------------------- 1 | interface PendingMessage { 2 | id: string 3 | type: "direct" | "group" 4 | from: string 5 | to?: string 6 | groupId?: string 7 | content: string 8 | timestamp: number 9 | retryCount: number 10 | } 11 | 12 | export class OfflineSyncService { 13 | private static instance: OfflineSyncService 14 | private pendingMessages: Map = new Map() 15 | private syncInProgress = false 16 | private maxRetries = 3 17 | 18 | static getInstance(): OfflineSyncService { 19 | if (!OfflineSyncService.instance) { 20 | OfflineSyncService.instance = new OfflineSyncService() 21 | } 22 | return OfflineSyncService.instance 23 | } 24 | 25 | constructor() { 26 | this.loadPendingMessages() 27 | this.setupEventListeners() 28 | } 29 | 30 | private loadPendingMessages(): void { 31 | try { 32 | const stored = localStorage.getItem("solchat_pending_messages") 33 | if (stored) { 34 | const messages = JSON.parse(stored) 35 | this.pendingMessages = new Map(Object.entries(messages)) 36 | } 37 | } catch (error) { 38 | console.error("Failed to load pending messages:", error) 39 | } 40 | } 41 | 42 | private savePendingMessages(): void { 43 | try { 44 | const messages = Object.fromEntries(this.pendingMessages) 45 | localStorage.setItem("solchat_pending_messages", JSON.stringify(messages)) 46 | } catch (error) { 47 | console.error("Failed to save pending messages:", error) 48 | } 49 | } 50 | 51 | private setupEventListeners(): void { 52 | window.addEventListener("online", () => { 53 | if (this.pendingMessages.size > 0) { 54 | this.syncPendingMessages() 55 | } 56 | }) 57 | } 58 | 59 | async queueMessage(message: Omit): Promise { 60 | const id = `pending_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` 61 | const pendingMessage: PendingMessage = { 62 | ...message, 63 | id, 64 | retryCount: 0, 65 | } 66 | 67 | this.pendingMessages.set(id, pendingMessage) 68 | this.savePendingMessages() 69 | 70 | // Try to sync immediately if online 71 | if (navigator.onLine) { 72 | this.syncPendingMessages() 73 | } 74 | 75 | return id 76 | } 77 | 78 | async syncPendingMessages(): Promise { 79 | if (this.syncInProgress || !navigator.onLine || this.pendingMessages.size === 0) { 80 | return 81 | } 82 | 83 | this.syncInProgress = true 84 | const successfulSyncs: string[] = [] 85 | const failedSyncs: string[] = [] 86 | 87 | try { 88 | for (const [id, message] of this.pendingMessages) { 89 | try { 90 | const success = await this.sendMessage(message) 91 | if (success) { 92 | successfulSyncs.push(id) 93 | } else { 94 | message.retryCount++ 95 | if (message.retryCount >= this.maxRetries) { 96 | failedSyncs.push(id) 97 | } 98 | } 99 | } catch (error) { 100 | console.error(`Failed to sync message ${id}:`, error) 101 | message.retryCount++ 102 | if (message.retryCount >= this.maxRetries) { 103 | failedSyncs.push(id) 104 | } 105 | } 106 | } 107 | 108 | // Remove successfully synced messages 109 | successfulSyncs.forEach((id) => { 110 | this.pendingMessages.delete(id) 111 | }) 112 | 113 | // Remove failed messages that exceeded retry limit 114 | failedSyncs.forEach((id) => { 115 | this.pendingMessages.delete(id) 116 | }) 117 | 118 | this.savePendingMessages() 119 | 120 | // Dispatch sync complete event 121 | window.dispatchEvent( 122 | new CustomEvent("offlineSyncComplete", { 123 | detail: { 124 | synced: successfulSyncs.length, 125 | failed: failedSyncs.length, 126 | pending: this.pendingMessages.size, 127 | }, 128 | }), 129 | ) 130 | } finally { 131 | this.syncInProgress = false 132 | } 133 | } 134 | 135 | private async sendMessage(message: PendingMessage): Promise { 136 | try { 137 | // Simulate message sending - in a real app, this would call your message service 138 | // For now, we'll just simulate a delay and return success 139 | await new Promise((resolve) => setTimeout(resolve, 1000)) 140 | 141 | // In a real implementation, you would: 142 | // 1. Encrypt the message 143 | // 2. Sign it with the wallet 144 | // 3. Store it on Arweave 145 | // 4. Update local storage 146 | 147 | console.log(`Syncing message: ${message.content.slice(0, 50)}...`) 148 | 149 | // Simulate 90% success rate 150 | return Math.random() > 0.1 151 | } catch (error) { 152 | console.error("Failed to send message:", error) 153 | return false 154 | } 155 | } 156 | 157 | getPendingMessagesCount(): number { 158 | return this.pendingMessages.size 159 | } 160 | 161 | getPendingMessages(): PendingMessage[] { 162 | return Array.from(this.pendingMessages.values()) 163 | } 164 | 165 | clearPendingMessages(): void { 166 | this.pendingMessages.clear() 167 | this.savePendingMessages() 168 | } 169 | 170 | removePendingMessage(id: string): boolean { 171 | const removed = this.pendingMessages.delete(id) 172 | if (removed) { 173 | this.savePendingMessages() 174 | } 175 | return removed 176 | } 177 | 178 | retryMessage(id: string): void { 179 | const message = this.pendingMessages.get(id) 180 | if (message) { 181 | message.retryCount = 0 182 | this.savePendingMessages() 183 | 184 | if (navigator.onLine) { 185 | this.syncPendingMessages() 186 | } 187 | } 188 | } 189 | 190 | getSyncStatus(): { 191 | pending: number 192 | syncing: boolean 193 | online: boolean 194 | } { 195 | return { 196 | pending: this.pendingMessages.size, 197 | syncing: this.syncInProgress, 198 | online: navigator.onLine, 199 | } 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /app/lib/search.ts: -------------------------------------------------------------------------------- 1 | import type { Message, GroupMessage } from "./messages" 2 | 3 | export class SearchService { 4 | async searchMessages( 5 | query: string, 6 | messages: Message[], 7 | groupMessages: GroupMessage[], 8 | ): Promise<(Message | GroupMessage)[]> { 9 | const lowercaseQuery = query.toLowerCase() 10 | 11 | const messageResults = messages.filter((message) => message.content.toLowerCase().includes(lowercaseQuery)) 12 | 13 | const groupMessageResults = groupMessages.filter((message) => 14 | message.content.toLowerCase().includes(lowercaseQuery), 15 | ) 16 | 17 | // Combine and sort by timestamp (newest first) 18 | const allResults = [...messageResults, ...groupMessageResults] 19 | return allResults.sort((a, b) => b.timestamp - a.timestamp) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/lib/solana.ts: -------------------------------------------------------------------------------- 1 | import { type Connection, PublicKey } from "@solana/web3.js" 2 | 3 | export class SolanaService { 4 | private connection: Connection 5 | private addressCache = new Map() 6 | private readonly CACHE_DURATION = 5 * 60 * 1000 // 5 minutes 7 | 8 | constructor(connection: Connection) { 9 | this.connection = connection 10 | } 11 | 12 | /** 13 | * Validates if a string is a valid Solana address format 14 | */ 15 | async isValidAddress(address: string): Promise { 16 | try { 17 | // Check if the address is the correct length and format 18 | if (!address || address.length < 32 || address.length > 44) { 19 | return false 20 | } 21 | 22 | // Try to create a PublicKey object 23 | new PublicKey(address) 24 | return true 25 | } catch (error) { 26 | return false 27 | } 28 | } 29 | 30 | /** 31 | * Checks cache first, then validates address 32 | */ 33 | private getCachedValidation(address: string): boolean | null { 34 | const cached = this.addressCache.get(address) 35 | if (cached && Date.now() - cached.timestamp < this.CACHE_DURATION) { 36 | return cached.isValid 37 | } 38 | return null 39 | } 40 | 41 | /** 42 | * Caches validation result 43 | */ 44 | private setCachedValidation(address: string, isValid: boolean): void { 45 | this.addressCache.set(address, { 46 | isValid, 47 | timestamp: Date.now(), 48 | }) 49 | } 50 | 51 | /** 52 | * Check if the RPC connection is healthy 53 | */ 54 | async checkConnectionHealth(): Promise { 55 | try { 56 | const timeoutPromise = new Promise((_, reject) => { 57 | setTimeout(() => reject(new Error("Health check timeout")), 5000) 58 | }) 59 | 60 | const healthPromise = this.connection.getSlot("confirmed") 61 | await Promise.race([healthPromise, timeoutPromise]) 62 | 63 | return true 64 | } catch (error) { 65 | console.warn("RPC connection health check failed:", error) 66 | return false 67 | } 68 | } 69 | 70 | /** 71 | * Attempt to recover from connection issues 72 | */ 73 | async recoverConnection(): Promise { 74 | try { 75 | // Clear any cached connection state 76 | this.clearCache() 77 | 78 | // Test the connection 79 | const isHealthy = await this.checkConnectionHealth() 80 | 81 | if (isHealthy) { 82 | console.log("Connection recovered successfully") 83 | return true 84 | } else { 85 | console.warn("Connection recovery failed") 86 | return false 87 | } 88 | } catch (error) { 89 | console.error("Connection recovery error:", error) 90 | return false 91 | } 92 | } 93 | 94 | /** 95 | * Enhanced address validation with network resilience 96 | */ 97 | async validateAddress(address: string): Promise<{ 98 | isValid: boolean 99 | error?: string 100 | fromCache?: boolean 101 | networkIssue?: boolean 102 | }> { 103 | try { 104 | // Check cache first 105 | const cached = this.getCachedValidation(address) 106 | if (cached !== null) { 107 | return { isValid: cached, fromCache: true } 108 | } 109 | 110 | // Validate format first (this doesn't require network) 111 | const isValidFormat = await this.isValidAddress(address) 112 | if (!isValidFormat) { 113 | this.setCachedValidation(address, false) 114 | return { 115 | isValid: false, 116 | error: "Invalid Solana address format", 117 | } 118 | } 119 | 120 | // For format validation, we can consider it valid even without network 121 | // This provides better UX during network issues 122 | this.setCachedValidation(address, true) 123 | 124 | // Try network validation but don't fail if network is down 125 | try { 126 | const networkExists = await this.checkNetworkExistence(address) 127 | // Network check is supplementary, don't override format validation 128 | return { 129 | isValid: true, 130 | networkIssue: !networkExists && !navigator.onLine, 131 | } 132 | } catch (networkError) { 133 | // Network issue, but format is valid 134 | return { 135 | isValid: true, 136 | networkIssue: true, 137 | } 138 | } 139 | } catch (error) { 140 | return { 141 | isValid: false, 142 | error: `Validation failed: ${error instanceof Error ? error.message : "Unknown error"}`, 143 | } 144 | } 145 | } 146 | 147 | /** 148 | * Optional network check (can fail gracefully) 149 | */ 150 | async checkNetworkExistence(address: string): Promise { 151 | try { 152 | const publicKey = new PublicKey(address) 153 | 154 | // Quick timeout to avoid hanging 155 | const timeoutPromise = new Promise((_, reject) => { 156 | setTimeout(() => reject(new Error("Timeout")), 3000) 157 | }) 158 | 159 | const accountPromise = this.connection.getAccountInfo(publicKey, "confirmed") 160 | 161 | await Promise.race([accountPromise, timeoutPromise]) 162 | return true 163 | } catch (error) { 164 | // Don't throw, just return false for network checks 165 | console.warn("Network check failed (this is okay):", error) 166 | return false 167 | } 168 | } 169 | 170 | /** 171 | * Gets account balance with timeout 172 | */ 173 | async getBalance(address: string): Promise { 174 | try { 175 | const publicKey = new PublicKey(address) 176 | 177 | const timeoutPromise = new Promise((_, reject) => { 178 | setTimeout(() => reject(new Error("Timeout")), 3000) 179 | }) 180 | 181 | const balancePromise = this.connection.getBalance(publicKey, "confirmed") 182 | const balance = await Promise.race([balancePromise, timeoutPromise]) 183 | 184 | return balance / 1e9 // Convert lamports to SOL 185 | } catch (error) { 186 | console.warn("Balance check failed:", error) 187 | return 0 188 | } 189 | } 190 | 191 | /** 192 | * Clear cache 193 | */ 194 | clearCache(): void { 195 | this.addressCache.clear() 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { WalletProvider } from "./components/WalletProvider" 4 | import { MainApp } from "./components/MainApp" 5 | import { MessageTest } from "./components/MessageTest" 6 | import { Toaster } from "@/components/ui/toaster" 7 | import { useState } from "react" 8 | import { Button } from "@/components/ui/button" 9 | 10 | export default function Home() { 11 | const [showTest, setShowTest] = useState(false) 12 | 13 | return ( 14 | 15 |
16 | {showTest ? ( 17 |
18 |
19 |

Message System Test

20 | 23 |
24 | 25 |
26 | ) : ( 27 | <> 28 | 29 |
30 | 38 |
39 | 40 | )} 41 |
42 | 43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /components/BurnerWallet.svelte: -------------------------------------------------------------------------------- 1 | 48 | 49 |
50 |
51 |
52 |
53 | 54 |

Burner Mode

55 |
56 | 57 |
58 | 59 |
60 |
61 |

Temporary Wallet

62 | {#if burnerWalletStore.keypair} 63 |

64 | {burnerWalletStore.keypair.publicKey.toString()} 65 |

66 | 73 | {:else} 74 |

Generating burner wallet...

75 | {/if} 76 |
77 | 78 |
79 |

⚠️ Warning

80 |
    81 |
  • • Messages self-destruct after expiration
  • 82 |
  • • Wallet is temporary and will be lost
  • 83 |
  • • No message recovery possible
  • 84 |
  • • Use for sensitive communications only
  • 85 |
86 |
87 |
88 | 89 | 90 |
91 |

Send Self-Destructing Message

92 | 93 |
94 | 97 | 103 |
104 | 105 |
106 | 109 | 116 |
117 | 118 |
119 | 123 | 133 |
134 | 135 | 143 |
144 |
145 | 146 | 147 | {#if burnerWalletStore.messages.length > 0} 148 |
149 |

Burner Messages

150 |
151 | {#each burnerWalletStore.messages as message} 152 |
153 |
154 |
155 |

{message.content}

156 |

157 | To: {message.to.slice(0, 8)}... 158 |

159 |
160 |
161 |
162 | 163 | Expires in {Math.max(0, Math.floor((message.expiresAt - Date.now()) / 1000 / 60))}m 164 |
165 |
166 |
167 |
168 | {/each} 169 |
170 |
171 | {/if} 172 |
173 |
174 |
175 | -------------------------------------------------------------------------------- /components/ChatInterface.svelte: -------------------------------------------------------------------------------- 1 | 70 | 71 |
72 | 73 |
74 |
75 |
76 |

Contacts

77 | 83 |
84 | 85 | {#if showAddContact} 86 |
87 | 92 |
93 | 99 | 105 |
106 |
107 | {/if} 108 |
109 | 110 | 111 |
112 | 113 | 114 |
115 | {#if selectedContact} 116 | 117 |
118 |

{selectedContact.name}

119 |

{selectedContact.address}

120 |
121 | 122 | 123 |
124 | {#each messageStoreValue as message (message.id)} 125 | {#if message.from === selectedContact.address || message.to === selectedContact.address} 126 | 127 | {/if} 128 | {/each} 129 |
130 | 131 | 132 |
133 |
134 | 141 | 148 |
149 |
150 | {:else} 151 |
152 |
153 |

Select a contact

154 |

Choose a contact to start messaging

155 |
156 |
157 | {/if} 158 |
159 |
160 | -------------------------------------------------------------------------------- /components/ContactList.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 |
20 | {#each $contactStore as contact (contact.address)} 21 | {@const lastMessage = getLastMessage(contact)} 22 | 45 | {/each} 46 | 47 | {#if $contactStore.length === 0} 48 |
49 |

No contacts yet

50 |

Add a contact to start messaging

51 |
52 | {/if} 53 |
54 | -------------------------------------------------------------------------------- /components/MessageBubble.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 |
24 |
25 | {#if isExpired} 26 |
27 |
28 | 29 | This message has expired 30 |
31 |
32 | {:else} 33 |
34 |

{message.content}

35 |
36 | {formatTimestamp(message.timestamp)} 37 |
38 | {#if message.expiresAt} 39 |
40 | 41 | Expires 42 |
43 | {/if} 44 | {#if isOwnMessage} 45 | 51 | {/if} 52 |
53 |
54 |
55 | {/if} 56 |
57 |
58 | -------------------------------------------------------------------------------- /components/WalletConnect.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | {#if $walletStore.connected} 26 | 33 | {:else} 34 | 42 | {/if} 43 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { 5 | ThemeProvider as NextThemesProvider, 6 | type ThemeProviderProps, 7 | } from 'next-themes' 8 | 9 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 10 | return {children} 11 | } 12 | -------------------------------------------------------------------------------- /components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDown } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Accordion = AccordionPrimitive.Root 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 20 | )) 21 | AccordionItem.displayName = "AccordionItem" 22 | 23 | const AccordionTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, children, ...props }, ref) => ( 27 | 28 | svg]:rotate-180", 32 | className 33 | )} 34 | {...props} 35 | > 36 | {children} 37 | 38 | 39 | 40 | )) 41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 42 | 43 | const AccordionContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, children, ...props }, ref) => ( 47 | 52 |
{children}
53 |
54 | )) 55 | 56 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 57 | 58 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 59 | -------------------------------------------------------------------------------- /components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = AlertDialogPrimitive.Portal 14 | 15 | const AlertDialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 29 | 30 | const AlertDialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 35 | 36 | 44 | 45 | )) 46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 47 | 48 | const AlertDialogHeader = ({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) => ( 52 |
59 | ) 60 | AlertDialogHeader.displayName = "AlertDialogHeader" 61 | 62 | const AlertDialogFooter = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
73 | ) 74 | AlertDialogFooter.displayName = "AlertDialogFooter" 75 | 76 | const AlertDialogTitle = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, ...props }, ref) => ( 80 | 85 | )) 86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 87 | 88 | const AlertDialogDescription = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 97 | )) 98 | AlertDialogDescription.displayName = 99 | AlertDialogPrimitive.Description.displayName 100 | 101 | const AlertDialogAction = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 112 | 113 | const AlertDialogCancel = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 126 | )) 127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 128 | 129 | export { 130 | AlertDialog, 131 | AlertDialogPortal, 132 | AlertDialogOverlay, 133 | AlertDialogTrigger, 134 | AlertDialogContent, 135 | AlertDialogHeader, 136 | AlertDialogFooter, 137 | AlertDialogTitle, 138 | AlertDialogDescription, 139 | AlertDialogAction, 140 | AlertDialogCancel, 141 | } 142 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 4 | 5 | const AspectRatio = AspectRatioPrimitive.Root 6 | 7 | export { AspectRatio } 8 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode 11 | } 12 | >(({ ...props }, ref) =>