mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-21 23:47:56 +00:00
feat(web): add PEZMessage on-chain E2E encrypted messaging UI
- x25519 ECDH + XChaCha20-Poly1305 encryption via @noble libs - Key derivation from wallet signRaw, private key held in memory only - Messaging pallet integration (registerEncryptionKey, sendMessage, inbox) - Inbox polling every 12s, auto-decrypt when key unlocked - ComposeDialog with recipient key validation and 512-byte limit - Settings moved from grid to nav bar gear icon, PEZMessage takes its slot - i18n translations for all 6 languages (en, tr, kmr, ckb, ar, fa)
This commit is contained in:
Generated
+184
-13
@@ -9,6 +9,9 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@noble/ciphers": "^2.1.1",
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@pezkuwi/api": "^16.5.36",
|
||||
"@pezkuwi/extension-dapp": "^0.62.20",
|
||||
"@pezkuwi/keyring": "^14.0.25",
|
||||
@@ -1149,39 +1152,39 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
|
||||
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
|
||||
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
||||
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.8.0"
|
||||
"@noble/hashes": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
@@ -1452,6 +1455,33 @@
|
||||
"@noble/hashes": "~1.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pezkuwi/scure-sr25519/node_modules/@noble/curves": {
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
|
||||
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@pezkuwi/scure-sr25519/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@pezkuwi/types": {
|
||||
"version": "16.5.36",
|
||||
"resolved": "https://registry.npmjs.org/@pezkuwi/types/-/types-16.5.36.tgz",
|
||||
@@ -1593,6 +1623,33 @@
|
||||
"@pezkuwi/util": "14.0.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@pezkuwi/util-crypto/node_modules/@noble/curves": {
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
|
||||
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@pezkuwi/util-crypto/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@pezkuwi/wasm-bridge": {
|
||||
"version": "7.5.17",
|
||||
"resolved": "https://registry.npmjs.org/@pezkuwi/wasm-bridge/-/wasm-bridge-7.5.17.tgz",
|
||||
@@ -1683,6 +1740,18 @@
|
||||
"@pezkuwi/util": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@pezkuwi/wasm-crypto/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@pezkuwi/wasm-util": {
|
||||
"version": "7.5.17",
|
||||
"resolved": "https://registry.npmjs.org/@pezkuwi/wasm-util/-/wasm-util-7.5.17.tgz",
|
||||
@@ -3885,6 +3954,33 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/curves": {
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
|
||||
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
|
||||
@@ -3898,6 +3994,18 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/browser-utils": {
|
||||
"version": "10.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.38.0.tgz",
|
||||
@@ -5656,6 +5764,45 @@
|
||||
"uint8arrays": "3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@walletconnect/utils/node_modules/@noble/ciphers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@walletconnect/utils/node_modules/@noble/curves": {
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
|
||||
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@walletconnect/utils/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@walletconnect/utils/node_modules/@walletconnect/keyvaluestorage": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@walletconnect/keyvaluestorage/-/keyvaluestorage-1.1.1.tgz",
|
||||
@@ -10319,6 +10466,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ox/node_modules/@noble/ciphers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/ox/node_modules/@noble/curves": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz",
|
||||
@@ -10334,6 +10493,18 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/ox/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/ox/node_modules/eventemitter3": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@noble/ciphers": "^2.1.1",
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@pezkuwi/api": "^16.5.36",
|
||||
"@pezkuwi/extension-dapp": "^0.62.20",
|
||||
"@pezkuwi/keyring": "^14.0.25",
|
||||
|
||||
@@ -61,6 +61,7 @@ const Forum = lazy(() => import('@/pages/Forum'));
|
||||
const ForumTopic = lazy(() => import('@/pages/ForumTopic'));
|
||||
const Telemetry = lazy(() => import('@/pages/Telemetry'));
|
||||
const Subdomains = lazy(() => import('@/pages/Subdomains'));
|
||||
const Messaging = lazy(() => import('@/pages/Messaging'));
|
||||
|
||||
// Network pages
|
||||
const Mainnet = lazy(() => import('@/pages/networks/Mainnet'));
|
||||
@@ -134,6 +135,7 @@ function App() {
|
||||
<Route path="/forum/:id" element={<ForumTopic />} />
|
||||
<Route path="/telemetry" element={<Telemetry />} />
|
||||
<Route path="/subdomains" element={<Subdomains />} />
|
||||
<Route path="/message" element={<Messaging />} />
|
||||
{/* Network pages */}
|
||||
<Route path="/mainnet" element={<Mainnet />} />
|
||||
<Route path="/staging" element={<Staging />} />
|
||||
|
||||
@@ -143,6 +143,15 @@ const AppLayout: React.FC = () => {
|
||||
|
||||
<NotificationBell />
|
||||
<LanguageSwitcher />
|
||||
{user && (
|
||||
<button
|
||||
onClick={() => navigate('/profile/settings')}
|
||||
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800 transition-colors"
|
||||
title={t('nav.settings')}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<PezkuwiWalletButton />
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,13 +187,13 @@ const AppLayout: React.FC = () => {
|
||||
<Users className="w-3.5 h-3.5 sm:w-5 sm:h-5 text-cyan-400" />
|
||||
{t('nav.beCitizen')}
|
||||
</button>
|
||||
{/* Settings */}
|
||||
{/* PEZMessage */}
|
||||
<button
|
||||
onClick={() => { setOpenMenu(null); navigate('/profile/settings'); }}
|
||||
className="flex flex-col items-center gap-0.5 sm:gap-1 p-1 sm:p-2 rounded-xl bg-gray-900/70 border border-gray-500/40 text-[9px] sm:text-xs font-medium transition-all hover:scale-[1.03] active:scale-95 cursor-pointer text-gray-300 hover:text-white"
|
||||
onClick={() => { setOpenMenu(null); navigate('/message'); }}
|
||||
className="flex flex-col items-center gap-0.5 sm:gap-1 p-1 sm:p-2 rounded-xl bg-gray-900/70 border border-purple-500/40 text-[9px] sm:text-xs font-medium transition-all hover:scale-[1.03] active:scale-95 cursor-pointer text-gray-300 hover:text-white"
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5 sm:w-5 sm:h-5 text-gray-400" />
|
||||
{t('nav.settings')}
|
||||
<MessageSquare className="w-3.5 h-3.5 sm:w-5 sm:h-5 text-purple-400" />
|
||||
{t('nav.message')}
|
||||
</button>
|
||||
{/* Trading (dropdown) */}
|
||||
<div className="relative">
|
||||
@@ -289,6 +298,13 @@ const AppLayout: React.FC = () => {
|
||||
<Wallet className="w-5 h-5" />
|
||||
{t('nav.wallet')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { navigate('/message'); setMobileMenuOpen(false); }}
|
||||
className="w-full text-left px-4 py-3 rounded-lg text-purple-400 hover:bg-gray-800 flex items-center gap-3"
|
||||
>
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
{t('nav.message')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { navigate('/citizens'); setMobileMenuOpen(false); }}
|
||||
className="w-full text-left px-4 py-3 rounded-lg text-cyan-400 hover:bg-gray-800 flex items-center gap-3"
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Send, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { getEncryptionKey } from '@/lib/messaging/chain';
|
||||
|
||||
interface ComposeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSend: (recipient: string, message: string) => Promise<void>;
|
||||
sending: boolean;
|
||||
}
|
||||
|
||||
const MAX_MESSAGE_BYTES = 512;
|
||||
|
||||
export function ComposeDialog({ open, onOpenChange, onSend, sending }: ComposeDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { peopleApi, isPeopleReady } = usePezkuwi();
|
||||
|
||||
const [recipient, setRecipient] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [recipientError, setRecipientError] = useState<string | null>(null);
|
||||
const [checkingRecipient, setCheckingRecipient] = useState(false);
|
||||
|
||||
const messageBytes = new TextEncoder().encode(message).length;
|
||||
const isOverLimit = messageBytes > MAX_MESSAGE_BYTES;
|
||||
|
||||
const checkRecipientKey = useCallback(async (address: string) => {
|
||||
if (!peopleApi || !isPeopleReady || !address || address.length < 40) {
|
||||
setRecipientError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckingRecipient(true);
|
||||
try {
|
||||
const key = await getEncryptionKey(peopleApi, address);
|
||||
if (!key) {
|
||||
setRecipientError(t('messaging.noRecipientKey', 'Recipient has no encryption key registered'));
|
||||
} else {
|
||||
setRecipientError(null);
|
||||
}
|
||||
} catch {
|
||||
setRecipientError(null);
|
||||
} finally {
|
||||
setCheckingRecipient(false);
|
||||
}
|
||||
}, [peopleApi, isPeopleReady, t]);
|
||||
|
||||
const handleRecipientChange = (value: string) => {
|
||||
setRecipient(value);
|
||||
setRecipientError(null);
|
||||
// Debounced check
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length >= 40) {
|
||||
checkRecipientKey(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
const trimmedRecipient = recipient.trim();
|
||||
const trimmedMessage = message.trim();
|
||||
|
||||
if (!trimmedRecipient || !trimmedMessage) return;
|
||||
|
||||
await onSend(trimmedRecipient, trimmedMessage);
|
||||
|
||||
// Clear form on success
|
||||
setRecipient('');
|
||||
setMessage('');
|
||||
setRecipientError(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const canSend = !sending &&
|
||||
!checkingRecipient &&
|
||||
!recipientError &&
|
||||
recipient.trim().length >= 40 &&
|
||||
message.trim().length > 0 &&
|
||||
!isOverLimit;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
{t('messaging.compose', 'New Message')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
{t('messaging.composeDesc', 'Send an end-to-end encrypted message on-chain.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Recipient */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">
|
||||
{t('messaging.recipient', 'Recipient Address')}
|
||||
</Label>
|
||||
<Input
|
||||
value={recipient}
|
||||
onChange={(e) => handleRecipientChange(e.target.value)}
|
||||
placeholder="5Cyu..."
|
||||
className="bg-gray-800 border-gray-700 text-white font-mono text-sm"
|
||||
disabled={sending}
|
||||
/>
|
||||
{recipientError && (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">{recipientError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{checkingRecipient && (
|
||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
{t('messaging.checkingKey', 'Checking encryption key...')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-gray-300">
|
||||
{t('messaging.message', 'Message')}
|
||||
</Label>
|
||||
<span className={`text-xs ${isOverLimit ? 'text-red-400' : 'text-gray-500'}`}>
|
||||
{messageBytes}/{MAX_MESSAGE_BYTES} bytes
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder={t('messaging.typePlaceholder', 'Type your message...')}
|
||||
className="bg-gray-800 border-gray-700 text-white min-h-[120px] resize-none"
|
||||
disabled={sending}
|
||||
/>
|
||||
{isOverLimit && (
|
||||
<p className="text-xs text-red-400">
|
||||
{t('messaging.tooLong', 'Message exceeds maximum size (512 bytes)')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={sending}
|
||||
className="border-gray-700 text-gray-300"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!canSend}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{t('messaging.sending', 'Sending...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{t('messaging.send', 'Send')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Lock, CheckCircle } from 'lucide-react';
|
||||
import type { DecryptedMessage } from '@/hooks/useMessaging';
|
||||
|
||||
interface InboxMessageProps {
|
||||
message: DecryptedMessage;
|
||||
currentBlock?: number;
|
||||
}
|
||||
|
||||
function formatAddress(addr: string): string {
|
||||
if (addr.length <= 12) return addr;
|
||||
return `${addr.slice(0, 6)}...${addr.slice(-4)}`;
|
||||
}
|
||||
|
||||
function formatTimeAgo(blockNumber: number, currentBlock: number): string {
|
||||
const blocksDiff = currentBlock - blockNumber;
|
||||
if (blocksDiff < 0) return '';
|
||||
const seconds = blocksDiff * 12; // ~12s per block
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
export function InboxMessage({ message, currentBlock }: InboxMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
const isDecrypted = message.plaintext !== null;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 p-3 sm:p-4 rounded-lg bg-gray-900/50 border border-gray-800 hover:border-gray-700 transition-colors">
|
||||
{/* Status icon */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{isDecrypted ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<Lock className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-gray-400" title={message.sender}>
|
||||
{formatAddress(message.sender)}
|
||||
</span>
|
||||
{currentBlock && message.blockNumber > 0 && (
|
||||
<span className="text-xs text-gray-600">
|
||||
{formatTimeAgo(message.blockNumber, currentBlock)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isDecrypted ? (
|
||||
<p className="text-sm text-gray-200 break-words whitespace-pre-wrap">
|
||||
{message.plaintext}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 italic">
|
||||
{t('messaging.encrypted', '[Encrypted]')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Block number */}
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-[10px] text-gray-600 font-mono">
|
||||
#{message.blockNumber}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { KeyRound, Loader2, Unlock } from 'lucide-react';
|
||||
|
||||
interface KeySetupProps {
|
||||
isKeyRegistered: boolean;
|
||||
isKeyUnlocked: boolean;
|
||||
registering: boolean;
|
||||
onSetupKey: () => void;
|
||||
onUnlockKey: () => void;
|
||||
}
|
||||
|
||||
export function KeySetup({
|
||||
isKeyRegistered,
|
||||
isKeyUnlocked,
|
||||
registering,
|
||||
onSetupKey,
|
||||
onUnlockKey,
|
||||
}: KeySetupProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isKeyRegistered && isKeyUnlocked) return null;
|
||||
|
||||
return (
|
||||
<Card className="border-yellow-500/30 bg-yellow-500/5">
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center">
|
||||
{isKeyRegistered ? (
|
||||
<Unlock className="w-5 h-5 text-yellow-400" />
|
||||
) : (
|
||||
<KeyRound className="w-5 h-5 text-yellow-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
{isKeyRegistered
|
||||
? t('messaging.unlockTitle', 'Unlock Your Messages')
|
||||
: t('messaging.setupTitle', 'Set Up Encryption Key')}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{isKeyRegistered
|
||||
? t('messaging.unlockDesc', 'Sign with your wallet to unlock message decryption for this session.')
|
||||
: t('messaging.setupDesc', 'Register your encryption key on-chain to start sending and receiving encrypted messages.')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={isKeyRegistered ? onUnlockKey : onSetupKey}
|
||||
disabled={registering}
|
||||
className="bg-yellow-500 hover:bg-yellow-600 text-black font-medium flex-shrink-0"
|
||||
>
|
||||
{registering ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{t('messaging.signing', 'Signing...')}
|
||||
</>
|
||||
) : isKeyRegistered ? (
|
||||
<>
|
||||
<Unlock className="w-4 h-4 mr-2" />
|
||||
{t('messaging.unlock', 'Unlock')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KeyRound className="w-4 h-4 mr-2" />
|
||||
{t('messaging.setupKey', 'Setup Key')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { deriveKeypair, encryptMessage, decryptMessage } from '@/lib/messaging/crypto';
|
||||
import {
|
||||
getEncryptionKey,
|
||||
getCurrentEra,
|
||||
getInbox,
|
||||
getSendCount,
|
||||
buildRegisterKeyTx,
|
||||
buildSendMessageTx,
|
||||
buildAcknowledgeTx,
|
||||
type EncryptedMessage,
|
||||
} from '@/lib/messaging/chain';
|
||||
import { getSigner } from '@/lib/get-signer';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface DecryptedMessage {
|
||||
sender: string;
|
||||
blockNumber: number;
|
||||
plaintext: string | null; // null if decryption failed
|
||||
raw: EncryptedMessage;
|
||||
}
|
||||
|
||||
interface MessagingState {
|
||||
isKeyRegistered: boolean;
|
||||
isKeyUnlocked: boolean;
|
||||
inbox: EncryptedMessage[];
|
||||
decryptedMessages: DecryptedMessage[];
|
||||
era: number;
|
||||
sendCount: number;
|
||||
loading: boolean;
|
||||
sending: boolean;
|
||||
registering: boolean;
|
||||
}
|
||||
|
||||
export function useMessaging() {
|
||||
const { peopleApi, isPeopleReady, selectedAccount, walletSource } = usePezkuwi();
|
||||
const { signMessage } = useWallet();
|
||||
|
||||
const [state, setState] = useState<MessagingState>({
|
||||
isKeyRegistered: false,
|
||||
isKeyUnlocked: false,
|
||||
inbox: [],
|
||||
decryptedMessages: [],
|
||||
era: 0,
|
||||
sendCount: 0,
|
||||
loading: false,
|
||||
sending: false,
|
||||
registering: false,
|
||||
});
|
||||
|
||||
// Private key stored only in memory (cleared on unmount/page close)
|
||||
const privateKeyRef = useRef<Uint8Array | null>(null);
|
||||
const publicKeyRef = useRef<Uint8Array | null>(null);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Check if user has a registered encryption key on-chain
|
||||
const checkKeyRegistration = useCallback(async () => {
|
||||
if (!peopleApi || !isPeopleReady || !selectedAccount) return;
|
||||
|
||||
try {
|
||||
const key = await getEncryptionKey(peopleApi, selectedAccount.address);
|
||||
setState(prev => ({ ...prev, isKeyRegistered: key !== null }));
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Failed to check encryption key:', err);
|
||||
}
|
||||
}, [peopleApi, isPeopleReady, selectedAccount]);
|
||||
|
||||
// Derive encryption keys from wallet signature and register on-chain
|
||||
const setupKey = useCallback(async () => {
|
||||
if (!peopleApi || !isPeopleReady || !selectedAccount) {
|
||||
toast.error('Wallet not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, registering: true }));
|
||||
|
||||
try {
|
||||
// 1. Sign deterministic message to derive keys
|
||||
const signature = await signMessage('PEZMessage:v1');
|
||||
const { publicKey, privateKey } = deriveKeypair(signature);
|
||||
|
||||
// 2. Store keys in memory
|
||||
privateKeyRef.current = privateKey;
|
||||
publicKeyRef.current = publicKey;
|
||||
|
||||
// 3. Check if key is already registered on-chain
|
||||
const existingKey = await getEncryptionKey(peopleApi, selectedAccount.address);
|
||||
const alreadyRegistered = existingKey !== null &&
|
||||
existingKey.length === publicKey.length &&
|
||||
existingKey.every((b, i) => b === publicKey[i]);
|
||||
|
||||
if (alreadyRegistered) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isKeyRegistered: true,
|
||||
isKeyUnlocked: true,
|
||||
registering: false,
|
||||
}));
|
||||
toast.success('Encryption key unlocked');
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Register key on-chain
|
||||
const tx = buildRegisterKeyTx(peopleApi, publicKey);
|
||||
const injector = await getSigner(selectedAccount.address, walletSource, peopleApi);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
({ status, dispatchError }: { status: any; dispatchError?: any }) => {
|
||||
if (status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
let errorMsg = 'Transaction failed';
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = peopleApi.registry.findMetaError(dispatchError.asModule);
|
||||
errorMsg = `${decoded.section}.${decoded.name}`;
|
||||
}
|
||||
reject(new Error(errorMsg));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
).catch(reject);
|
||||
});
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isKeyRegistered: true,
|
||||
isKeyUnlocked: true,
|
||||
registering: false,
|
||||
}));
|
||||
toast.success('Encryption key registered');
|
||||
} catch (err) {
|
||||
setState(prev => ({ ...prev, registering: false }));
|
||||
const msg = err instanceof Error ? err.message : 'Failed to setup key';
|
||||
toast.error(msg);
|
||||
}
|
||||
}, [peopleApi, isPeopleReady, selectedAccount, walletSource, signMessage]);
|
||||
|
||||
// Unlock existing key (re-derive from signature without registering)
|
||||
const unlockKey = useCallback(async () => {
|
||||
if (!peopleApi || !selectedAccount) return;
|
||||
|
||||
setState(prev => ({ ...prev, registering: true }));
|
||||
try {
|
||||
const signature = await signMessage('PEZMessage:v1');
|
||||
const { publicKey, privateKey } = deriveKeypair(signature);
|
||||
privateKeyRef.current = privateKey;
|
||||
publicKeyRef.current = publicKey;
|
||||
setState(prev => ({ ...prev, isKeyUnlocked: true, registering: false }));
|
||||
toast.success('Encryption key unlocked');
|
||||
} catch {
|
||||
setState(prev => ({ ...prev, registering: false }));
|
||||
toast.error('Failed to unlock key');
|
||||
}
|
||||
}, [peopleApi, selectedAccount, signMessage]);
|
||||
|
||||
// Refresh inbox from chain
|
||||
const refreshInbox = useCallback(async () => {
|
||||
if (!peopleApi || !isPeopleReady || !selectedAccount) return;
|
||||
|
||||
try {
|
||||
const era = await getCurrentEra(peopleApi);
|
||||
const [inbox, sendCount] = await Promise.all([
|
||||
getInbox(peopleApi, era, selectedAccount.address),
|
||||
getSendCount(peopleApi, era, selectedAccount.address),
|
||||
]);
|
||||
|
||||
// Auto-decrypt if private key is available
|
||||
let decrypted: DecryptedMessage[] = [];
|
||||
if (privateKeyRef.current) {
|
||||
decrypted = inbox.map(msg => {
|
||||
try {
|
||||
const plaintext = decryptMessage(
|
||||
privateKeyRef.current!,
|
||||
msg.ephemeralPublicKey,
|
||||
msg.nonce,
|
||||
msg.ciphertext
|
||||
);
|
||||
return { sender: msg.sender, blockNumber: msg.blockNumber, plaintext, raw: msg };
|
||||
} catch {
|
||||
return { sender: msg.sender, blockNumber: msg.blockNumber, plaintext: null, raw: msg };
|
||||
}
|
||||
});
|
||||
} else {
|
||||
decrypted = inbox.map(msg => ({
|
||||
sender: msg.sender,
|
||||
blockNumber: msg.blockNumber,
|
||||
plaintext: null,
|
||||
raw: msg,
|
||||
}));
|
||||
}
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
era,
|
||||
inbox,
|
||||
sendCount,
|
||||
decryptedMessages: decrypted,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Failed to refresh inbox:', err);
|
||||
}
|
||||
}, [peopleApi, isPeopleReady, selectedAccount]);
|
||||
|
||||
// Send an encrypted message
|
||||
const sendEncryptedMessage = useCallback(async (recipient: string, text: string) => {
|
||||
if (!peopleApi || !isPeopleReady || !selectedAccount) {
|
||||
toast.error('Wallet not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, sending: true }));
|
||||
|
||||
try {
|
||||
// 1. Get recipient's public key
|
||||
const recipientPubKey = await getEncryptionKey(peopleApi, recipient);
|
||||
if (!recipientPubKey) {
|
||||
throw new Error('Recipient has no encryption key registered');
|
||||
}
|
||||
|
||||
// 2. Encrypt
|
||||
const { ephemeralPublicKey, nonce, ciphertext } = encryptMessage(recipientPubKey, text);
|
||||
|
||||
// 3. Build and send TX
|
||||
const tx = buildSendMessageTx(peopleApi, recipient, ephemeralPublicKey, nonce, ciphertext);
|
||||
const injector = await getSigner(selectedAccount.address, walletSource, peopleApi);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
({ status, dispatchError }: { status: any; dispatchError?: any }) => {
|
||||
if (status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
let errorMsg = 'Transaction failed';
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = peopleApi.registry.findMetaError(dispatchError.asModule);
|
||||
errorMsg = `${decoded.section}.${decoded.name}`;
|
||||
}
|
||||
reject(new Error(errorMsg));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
).catch(reject);
|
||||
});
|
||||
|
||||
toast.success('Message sent');
|
||||
setState(prev => ({ ...prev, sending: false }));
|
||||
// Refresh inbox to show updated send count
|
||||
await refreshInbox();
|
||||
} catch (err) {
|
||||
setState(prev => ({ ...prev, sending: false }));
|
||||
const msg = err instanceof Error ? err.message : 'Failed to send message';
|
||||
toast.error(msg);
|
||||
}
|
||||
}, [peopleApi, isPeopleReady, selectedAccount, walletSource, refreshInbox]);
|
||||
|
||||
// Acknowledge messages (optional, feeless)
|
||||
const acknowledge = useCallback(async () => {
|
||||
if (!peopleApi || !selectedAccount) return;
|
||||
|
||||
try {
|
||||
const tx = buildAcknowledgeTx(peopleApi);
|
||||
const injector = await getSigner(selectedAccount.address, walletSource, peopleApi);
|
||||
await tx.signAndSend(selectedAccount.address, { signer: injector.signer });
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Failed to acknowledge:', err);
|
||||
}
|
||||
}, [peopleApi, selectedAccount, walletSource]);
|
||||
|
||||
// Initial load + polling
|
||||
useEffect(() => {
|
||||
if (!peopleApi || !isPeopleReady || !selectedAccount) return;
|
||||
|
||||
setState(prev => ({ ...prev, loading: true }));
|
||||
|
||||
const init = async () => {
|
||||
await checkKeyRegistration();
|
||||
await refreshInbox();
|
||||
setState(prev => ({ ...prev, loading: false }));
|
||||
};
|
||||
init();
|
||||
|
||||
// Poll every 12 seconds (1 block interval)
|
||||
pollIntervalRef.current = setInterval(refreshInbox, 12000);
|
||||
|
||||
return () => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [peopleApi, isPeopleReady, selectedAccount, checkKeyRegistration, refreshInbox]);
|
||||
|
||||
// Clear private key when account changes
|
||||
useEffect(() => {
|
||||
privateKeyRef.current = null;
|
||||
publicKeyRef.current = null;
|
||||
setState(prev => ({ ...prev, isKeyUnlocked: false }));
|
||||
}, [selectedAccount?.address]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
setupKey,
|
||||
unlockKey,
|
||||
sendMessage: sendEncryptedMessage,
|
||||
refreshInbox,
|
||||
acknowledge,
|
||||
};
|
||||
}
|
||||
@@ -3727,4 +3727,34 @@ export default {
|
||||
'websocket.liveUpdates': 'التحديثات المباشرة مفعلة',
|
||||
'websocket.offlineMode': 'وضع عدم الاتصال',
|
||||
|
||||
// Navigation - Messaging
|
||||
'nav.message': 'رسالة',
|
||||
|
||||
// Messaging
|
||||
'messaging.title': 'PEZMessage',
|
||||
'messaging.connectWallet': 'ربط المحفظة',
|
||||
'messaging.connectWalletDesc': 'قم بربط محفظتك لاستخدام الرسائل المشفرة.',
|
||||
'messaging.setupTitle': 'إعداد مفتاح التشفير',
|
||||
'messaging.setupDesc': 'سجّل مفتاح التشفير الخاص بك على السلسلة لبدء إرسال واستقبال الرسائل المشفرة.',
|
||||
'messaging.unlockTitle': 'فتح رسائلك',
|
||||
'messaging.unlockDesc': 'وقّع بمحفظتك لتمكين فك تشفير الرسائل لهذه الجلسة.',
|
||||
'messaging.setupKey': 'إعداد المفتاح',
|
||||
'messaging.unlock': 'فتح',
|
||||
'messaging.signing': 'جاري التوقيع...',
|
||||
'messaging.newMessage': 'جديد',
|
||||
'messaging.messages': 'رسائل',
|
||||
'messaging.sent': 'مرسلة',
|
||||
'messaging.emptyInbox': 'لا توجد رسائل بعد.',
|
||||
'messaging.sendFirst': 'أرسل رسالتك الأولى',
|
||||
'messaging.encrypted': '[مشفّرة]',
|
||||
'messaging.compose': 'رسالة جديدة',
|
||||
'messaging.composeDesc': 'أرسل رسالة مشفرة من طرف إلى طرف على السلسلة.',
|
||||
'messaging.recipient': 'عنوان المستلم',
|
||||
'messaging.message': 'الرسالة',
|
||||
'messaging.typePlaceholder': 'اكتب رسالتك...',
|
||||
'messaging.tooLong': 'الرسالة تتجاوز الحد الأقصى (512 بايت)',
|
||||
'messaging.noRecipientKey': 'المستلم ليس لديه مفتاح تشفير مسجّل',
|
||||
'messaging.checkingKey': 'جاري التحقق من مفتاح التشفير...',
|
||||
'messaging.send': 'إرسال',
|
||||
'messaging.sending': 'جاري الإرسال...',
|
||||
};
|
||||
|
||||
@@ -3717,4 +3717,34 @@ export default {
|
||||
'websocket.liveUpdates': 'نوێکردنەوەی ڕاستەوخۆ چالاکە',
|
||||
'websocket.offlineMode': 'دۆخی ئۆفلاین',
|
||||
|
||||
// Navigation - Messaging
|
||||
'nav.message': 'نامە',
|
||||
|
||||
// Messaging
|
||||
'messaging.title': 'PEZMessage',
|
||||
'messaging.connectWallet': 'جزدان ببەستە',
|
||||
'messaging.connectWalletDesc': 'بۆ بەکارهێنانی نامەی شفرکراو جزدانەکەت ببەستە.',
|
||||
'messaging.setupTitle': 'کلیلی شفرکردن دابنێ',
|
||||
'messaging.setupDesc': 'بۆ ناردن و وەرگرتنی نامەی شفرکراو کلیلەکەت لەسەر زنجیرە تۆمار بکە.',
|
||||
'messaging.unlockTitle': 'نامەکانت بکەرەوە',
|
||||
'messaging.unlockDesc': 'بۆ کردنەوەی شفری نامەکان لەم دانیشتنەدا بە جزدانەکەت واژۆ بکە.',
|
||||
'messaging.setupKey': 'کلیل دابنێ',
|
||||
'messaging.unlock': 'بکەرەوە',
|
||||
'messaging.signing': 'واژۆدەکرێت...',
|
||||
'messaging.newMessage': 'نوێ',
|
||||
'messaging.messages': 'نامە',
|
||||
'messaging.sent': 'نێردراو',
|
||||
'messaging.emptyInbox': 'هێشتا نامەیەک نییە.',
|
||||
'messaging.sendFirst': 'یەکەم نامەکەت بنێرە',
|
||||
'messaging.encrypted': '[شفرکراو]',
|
||||
'messaging.compose': 'نامەی نوێ',
|
||||
'messaging.composeDesc': 'نامەیەکی شفرکراوی سەرەتاوەکۆتایی لەسەر زنجیرە بنێرە.',
|
||||
'messaging.recipient': 'ناونیشانی وەرگر',
|
||||
'messaging.message': 'نامە',
|
||||
'messaging.typePlaceholder': 'نامەکەت بنووسە...',
|
||||
'messaging.tooLong': 'نامە لە قەبارەی زۆرینە تێپەڕیوە (٥١٢ بایت)',
|
||||
'messaging.noRecipientKey': 'وەرگر کلیلی شفرکردنی تۆمارکراوی نییە',
|
||||
'messaging.checkingKey': 'کلیلی شفرکردن پشکنین دەکرێت...',
|
||||
'messaging.send': 'بنێرە',
|
||||
'messaging.sending': 'دەنێردرێت...',
|
||||
};
|
||||
|
||||
@@ -3763,5 +3763,36 @@ export default {
|
||||
'proposalCard.proposalClosedDesc': 'Proposal has been closed',
|
||||
'proposalCard.executeFailed': 'Execution Failed',
|
||||
'proposalCard.executeSuccess': 'Proposal Executed!',
|
||||
'proposalCard.executeKycApproved': 'KYC approved and NFT minted successfully!'
|
||||
'proposalCard.executeKycApproved': 'KYC approved and NFT minted successfully!',
|
||||
|
||||
// Navigation - Messaging
|
||||
'nav.message': 'Message',
|
||||
|
||||
// Messaging
|
||||
'messaging.title': 'PEZMessage',
|
||||
'messaging.connectWallet': 'Connect Wallet',
|
||||
'messaging.connectWalletDesc': 'Connect your wallet to use encrypted messaging.',
|
||||
'messaging.setupTitle': 'Set Up Encryption Key',
|
||||
'messaging.setupDesc': 'Register your encryption key on-chain to start sending and receiving encrypted messages.',
|
||||
'messaging.unlockTitle': 'Unlock Your Messages',
|
||||
'messaging.unlockDesc': 'Sign with your wallet to unlock message decryption for this session.',
|
||||
'messaging.setupKey': 'Setup Key',
|
||||
'messaging.unlock': 'Unlock',
|
||||
'messaging.signing': 'Signing...',
|
||||
'messaging.newMessage': 'New',
|
||||
'messaging.messages': 'messages',
|
||||
'messaging.sent': 'sent',
|
||||
'messaging.emptyInbox': 'No messages yet.',
|
||||
'messaging.sendFirst': 'Send your first message',
|
||||
'messaging.encrypted': '[Encrypted]',
|
||||
'messaging.compose': 'New Message',
|
||||
'messaging.composeDesc': 'Send an end-to-end encrypted message on-chain.',
|
||||
'messaging.recipient': 'Recipient Address',
|
||||
'messaging.message': 'Message',
|
||||
'messaging.typePlaceholder': 'Type your message...',
|
||||
'messaging.tooLong': 'Message exceeds maximum size (512 bytes)',
|
||||
'messaging.noRecipientKey': 'Recipient has no encryption key registered',
|
||||
'messaging.checkingKey': 'Checking encryption key...',
|
||||
'messaging.send': 'Send',
|
||||
'messaging.sending': 'Sending...',
|
||||
}
|
||||
|
||||
@@ -3761,4 +3761,34 @@ export default {
|
||||
'websocket.liveUpdates': 'بهروزرسانی زنده فعال',
|
||||
'websocket.offlineMode': 'حالت آفلاین',
|
||||
|
||||
// Navigation - Messaging
|
||||
'nav.message': 'پیام',
|
||||
|
||||
// Messaging
|
||||
'messaging.title': 'PEZMessage',
|
||||
'messaging.connectWallet': 'اتصال کیف پول',
|
||||
'messaging.connectWalletDesc': 'برای استفاده از پیامرسانی رمزنگاریشده کیف پول خود را متصل کنید.',
|
||||
'messaging.setupTitle': 'تنظیم کلید رمزنگاری',
|
||||
'messaging.setupDesc': 'کلید رمزنگاری خود را روی زنجیره ثبت کنید تا بتوانید پیامهای رمزنگاریشده ارسال و دریافت کنید.',
|
||||
'messaging.unlockTitle': 'باز کردن پیامها',
|
||||
'messaging.unlockDesc': 'برای فعالسازی رمزگشایی پیامها در این نشست با کیف پول خود امضا کنید.',
|
||||
'messaging.setupKey': 'تنظیم کلید',
|
||||
'messaging.unlock': 'باز کردن',
|
||||
'messaging.signing': 'در حال امضا...',
|
||||
'messaging.newMessage': 'جدید',
|
||||
'messaging.messages': 'پیام',
|
||||
'messaging.sent': 'ارسال شده',
|
||||
'messaging.emptyInbox': 'هنوز پیامی نیست.',
|
||||
'messaging.sendFirst': 'اولین پیام خود را ارسال کنید',
|
||||
'messaging.encrypted': '[رمزنگاریشده]',
|
||||
'messaging.compose': 'پیام جدید',
|
||||
'messaging.composeDesc': 'یک پیام رمزنگاریشده سرتاسری روی زنجیره ارسال کنید.',
|
||||
'messaging.recipient': 'آدرس گیرنده',
|
||||
'messaging.message': 'پیام',
|
||||
'messaging.typePlaceholder': 'پیام خود را بنویسید...',
|
||||
'messaging.tooLong': 'پیام از حداکثر اندازه فراتر رفته (۵۱۲ بایت)',
|
||||
'messaging.noRecipientKey': 'گیرنده کلید رمزنگاری ثبتشده ندارد',
|
||||
'messaging.checkingKey': 'بررسی کلید رمزنگاری...',
|
||||
'messaging.send': 'ارسال',
|
||||
'messaging.sending': 'در حال ارسال...',
|
||||
};
|
||||
|
||||
@@ -3744,4 +3744,34 @@ export default {
|
||||
'websocket.liveUpdates': 'Nûvekirinên zindî çalak in',
|
||||
'websocket.offlineMode': 'Moda Negirêdayî',
|
||||
|
||||
// Navigation - Messaging
|
||||
'nav.message': 'Peyam',
|
||||
|
||||
// Messaging
|
||||
'messaging.title': 'PEZMessage',
|
||||
'messaging.connectWallet': 'Berîka Xwe Girêbide',
|
||||
'messaging.connectWalletDesc': 'Ji bo bikaranîna peyamên şîfrekirî berîka xwe girêbide.',
|
||||
'messaging.setupTitle': 'Mifteya Şîfrekirinê Saz Bike',
|
||||
'messaging.setupDesc': 'Ji bo şandina û wergirtina peyamên şîfrekirî mifteya xwe li ser zincîrê tomar bike.',
|
||||
'messaging.unlockTitle': 'Peyamên Xwe Veke',
|
||||
'messaging.unlockDesc': 'Ji bo vekirina şîfreya peyaman di vê danişînê de bi berîka xwe re îmze bike.',
|
||||
'messaging.setupKey': 'Mifteyê Saz Bike',
|
||||
'messaging.unlock': 'Veke',
|
||||
'messaging.signing': 'Tê îmzekirin...',
|
||||
'messaging.newMessage': 'Nû',
|
||||
'messaging.messages': 'peyam',
|
||||
'messaging.sent': 'hatine şandin',
|
||||
'messaging.emptyInbox': 'Hîn peyam tune.',
|
||||
'messaging.sendFirst': 'Peyama xwe ya yekem bişîne',
|
||||
'messaging.encrypted': '[Şîfrekirî]',
|
||||
'messaging.compose': 'Peyama Nû',
|
||||
'messaging.composeDesc': 'Li ser zincîrê peyamek şîfrekirî ji destpêk heta dawiyê bişîne.',
|
||||
'messaging.recipient': 'Navnîşana Wergir',
|
||||
'messaging.message': 'Peyam',
|
||||
'messaging.typePlaceholder': 'Peyama xwe binivîse...',
|
||||
'messaging.tooLong': 'Peyam ji mezinahiya herî zêde derbas dibe (512 byte)',
|
||||
'messaging.noRecipientKey': 'Wergir mifteya şîfrekirinê tune ye',
|
||||
'messaging.checkingKey': 'Mifteya şîfrekirinê tê kontrol kirin...',
|
||||
'messaging.send': 'Bişîne',
|
||||
'messaging.sending': 'Tê şandin...',
|
||||
};
|
||||
|
||||
@@ -3746,4 +3746,35 @@ export default {
|
||||
'websocket.reconnecting': 'Yeniden bağlanıyor...',
|
||||
'websocket.liveUpdates': 'Canlı güncellemeler etkin',
|
||||
'websocket.offlineMode': 'Çevrimdışı mod',
|
||||
|
||||
// Navigation - Messaging
|
||||
'nav.message': 'Mesaj',
|
||||
|
||||
// Messaging
|
||||
'messaging.title': 'PEZMessage',
|
||||
'messaging.connectWallet': 'Cüzdan Bağla',
|
||||
'messaging.connectWalletDesc': 'Şifreli mesajlaşmayı kullanmak için cüzdanınızı bağlayın.',
|
||||
'messaging.setupTitle': 'Şifreleme Anahtarı Oluştur',
|
||||
'messaging.setupDesc': 'Şifreli mesaj gönderip almak için şifreleme anahtarınızı zincire kaydedin.',
|
||||
'messaging.unlockTitle': 'Mesajlarınızı Açın',
|
||||
'messaging.unlockDesc': 'Bu oturum için mesaj şifre çözümünü etkinleştirmek üzere cüzdanınızla imzalayın.',
|
||||
'messaging.setupKey': 'Anahtar Oluştur',
|
||||
'messaging.unlock': 'Kilidi Aç',
|
||||
'messaging.signing': 'İmzalanıyor...',
|
||||
'messaging.newMessage': 'Yeni',
|
||||
'messaging.messages': 'mesaj',
|
||||
'messaging.sent': 'gönderildi',
|
||||
'messaging.emptyInbox': 'Henüz mesaj yok.',
|
||||
'messaging.sendFirst': 'İlk mesajınızı gönderin',
|
||||
'messaging.encrypted': '[Şifreli]',
|
||||
'messaging.compose': 'Yeni Mesaj',
|
||||
'messaging.composeDesc': 'Zincir üzerinde uçtan uca şifreli mesaj gönderin.',
|
||||
'messaging.recipient': 'Alıcı Adresi',
|
||||
'messaging.message': 'Mesaj',
|
||||
'messaging.typePlaceholder': 'Mesajınızı yazın...',
|
||||
'messaging.tooLong': 'Mesaj maksimum boyutu aşıyor (512 bayt)',
|
||||
'messaging.noRecipientKey': 'Alıcının kayıtlı şifreleme anahtarı yok',
|
||||
'messaging.checkingKey': 'Şifreleme anahtarı kontrol ediliyor...',
|
||||
'messaging.send': 'Gönder',
|
||||
'messaging.sending': 'Gönderiliyor...',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import { hexToBytes, bytesToHex } from './crypto';
|
||||
|
||||
export interface EncryptedMessage {
|
||||
sender: string;
|
||||
blockNumber: number;
|
||||
ephemeralPublicKey: Uint8Array;
|
||||
nonce: Uint8Array;
|
||||
ciphertext: Uint8Array;
|
||||
}
|
||||
|
||||
// --- Storage queries ---
|
||||
|
||||
export async function getEncryptionKey(
|
||||
api: ApiPromise,
|
||||
address: string
|
||||
): Promise<Uint8Array | null> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await (api.query as any).messaging.encryptionKeys(address);
|
||||
if (result.isNone || result.isEmpty) return null;
|
||||
const hex = result.unwrap().toHex();
|
||||
return hexToBytes(hex);
|
||||
}
|
||||
|
||||
export async function getCurrentEra(api: ApiPromise): Promise<number> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const era = await (api.query as any).messaging.currentEra();
|
||||
return era.toNumber();
|
||||
}
|
||||
|
||||
export async function getInbox(
|
||||
api: ApiPromise,
|
||||
era: number,
|
||||
address: string
|
||||
): Promise<EncryptedMessage[]> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await (api.query as any).messaging.inbox([era, address]);
|
||||
if (result.isEmpty || result.length === 0) return [];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return result.map((msg: Record<string, any>) => ({
|
||||
sender: msg.sender.toString(),
|
||||
blockNumber: msg.blockNumber?.toNumber?.() ?? msg.block_number?.toNumber?.() ?? 0,
|
||||
ephemeralPublicKey: hexToBytes(
|
||||
msg.ephemeralPublicKey?.toHex?.() ?? msg.ephemeral_public_key?.toHex?.() ?? '0x'
|
||||
),
|
||||
nonce: hexToBytes(msg.nonce.toHex()),
|
||||
ciphertext: hexToBytes(msg.ciphertext.toHex()),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getSendCount(
|
||||
api: ApiPromise,
|
||||
era: number,
|
||||
address: string
|
||||
): Promise<number> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const count = await (api.query as any).messaging.sendCount([era, address]);
|
||||
return count.toNumber();
|
||||
}
|
||||
|
||||
// --- TX builders ---
|
||||
|
||||
export function buildRegisterKeyTx(api: ApiPromise, publicKey: Uint8Array) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (api.tx as any).messaging.registerEncryptionKey(bytesToHex(publicKey));
|
||||
}
|
||||
|
||||
export function buildSendMessageTx(
|
||||
api: ApiPromise,
|
||||
recipient: string,
|
||||
ephemeralPubKey: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
ciphertext: Uint8Array
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (api.tx as any).messaging.sendMessage(
|
||||
recipient,
|
||||
bytesToHex(ephemeralPubKey),
|
||||
bytesToHex(nonce),
|
||||
bytesToHex(ciphertext)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAcknowledgeTx(api: ApiPromise) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (api.tx as any).messaging.acknowledgeMessages();
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { x25519 } from '@noble/curves/ed25519.js';
|
||||
import { xchacha20poly1305 } from '@noble/ciphers/chacha.js';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import { randomBytes } from '@noble/ciphers/utils.js';
|
||||
|
||||
/**
|
||||
* Derive a deterministic x25519 keypair from a wallet signature.
|
||||
* User signs "PEZMessage:v1" → SHA-256 of signature → x25519 seed.
|
||||
*/
|
||||
export function deriveKeypair(signatureHex: string): {
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
} {
|
||||
// Remove 0x prefix if present
|
||||
const hex = signatureHex.startsWith('0x') ? signatureHex.slice(2) : signatureHex;
|
||||
const sigBytes = hexToBytes(hex);
|
||||
const privateKey = sha256(sigBytes);
|
||||
const publicKey = x25519.getPublicKey(privateKey);
|
||||
return { publicKey, privateKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a message for a recipient using their x25519 public key.
|
||||
* Generates an ephemeral keypair, computes ECDH shared secret,
|
||||
* then encrypts with XChaCha20-Poly1305.
|
||||
*/
|
||||
export function encryptMessage(
|
||||
recipientPublicKey: Uint8Array,
|
||||
plaintext: string
|
||||
): {
|
||||
ephemeralPublicKey: Uint8Array;
|
||||
nonce: Uint8Array;
|
||||
ciphertext: Uint8Array;
|
||||
} {
|
||||
// Generate ephemeral x25519 keypair
|
||||
const ephemeralPrivate = randomBytes(32);
|
||||
const ephemeralPublicKey = x25519.getPublicKey(ephemeralPrivate);
|
||||
|
||||
// ECDH: ephemeral_private × recipient_public → shared_secret
|
||||
const rawShared = x25519.getSharedSecret(ephemeralPrivate, recipientPublicKey);
|
||||
const sharedKey = sha256(rawShared);
|
||||
|
||||
// Encrypt with XChaCha20-Poly1305
|
||||
const nonce = randomBytes(24);
|
||||
const plaintextBytes = new TextEncoder().encode(plaintext);
|
||||
const cipher = xchacha20poly1305(sharedKey, nonce);
|
||||
const ciphertext = cipher.encrypt(plaintextBytes);
|
||||
|
||||
return { ephemeralPublicKey, nonce, ciphertext };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a message using own private key and the sender's ephemeral public key.
|
||||
* Recomputes ECDH shared secret, then decrypts with XChaCha20-Poly1305.
|
||||
*/
|
||||
export function decryptMessage(
|
||||
privateKey: Uint8Array,
|
||||
ephemeralPublicKey: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
ciphertext: Uint8Array
|
||||
): string {
|
||||
// ECDH: my_private × ephemeral_public → shared_secret
|
||||
const rawShared = x25519.getSharedSecret(privateKey, ephemeralPublicKey);
|
||||
const sharedKey = sha256(rawShared);
|
||||
|
||||
// Decrypt with XChaCha20-Poly1305
|
||||
const cipher = xchacha20poly1305(sharedKey, nonce);
|
||||
const plaintextBytes = cipher.decrypt(ciphertext);
|
||||
|
||||
return new TextDecoder().decode(plaintextBytes);
|
||||
}
|
||||
|
||||
// --- Utility helpers ---
|
||||
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
const clean = hex.startsWith('0x') ? hex.slice(2) : hex;
|
||||
const bytes = new Uint8Array(clean.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = parseInt(clean.substr(i * 2, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
return '0x' + Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export function u8aToHex(bytes: Uint8Array): string {
|
||||
return bytesToHex(bytes);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useMessaging } from '@/hooks/useMessaging';
|
||||
import { KeySetup } from '@/components/messaging/KeySetup';
|
||||
import { InboxMessage } from '@/components/messaging/InboxMessage';
|
||||
import { ComposeDialog } from '@/components/messaging/ComposeDialog';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
MessageSquare,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Wallet,
|
||||
Inbox,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function Messaging() {
|
||||
const { t } = useTranslation();
|
||||
const { peopleApi, isPeopleReady, selectedAccount } = usePezkuwi();
|
||||
const {
|
||||
isKeyRegistered,
|
||||
isKeyUnlocked,
|
||||
decryptedMessages,
|
||||
era,
|
||||
sendCount,
|
||||
loading,
|
||||
sending,
|
||||
registering,
|
||||
setupKey,
|
||||
unlockKey,
|
||||
sendMessage,
|
||||
refreshInbox,
|
||||
} = useMessaging();
|
||||
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const [currentBlock, setCurrentBlock] = useState(0);
|
||||
|
||||
// Get current block number for time-ago display
|
||||
useEffect(() => {
|
||||
if (!peopleApi || !isPeopleReady) return;
|
||||
|
||||
let unsub: (() => void) | undefined;
|
||||
|
||||
const subscribe = async () => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
unsub = await (peopleApi.rpc.chain as any).subscribeNewHeads((header: { number: { toNumber: () => number } }) => {
|
||||
setCurrentBlock(header.number.toNumber());
|
||||
});
|
||||
} catch {
|
||||
// Fallback: single query
|
||||
try {
|
||||
const header = await peopleApi.rpc.chain.getHeader();
|
||||
setCurrentBlock(header.number.toNumber());
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
subscribe();
|
||||
return () => { unsub?.(); };
|
||||
}, [peopleApi, isPeopleReady]);
|
||||
|
||||
// No wallet connected
|
||||
if (!selectedAccount) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 pt-20 sm:pt-[8.5rem]">
|
||||
<div className="max-w-2xl mx-auto px-4 py-12">
|
||||
<Card className="border-gray-800 bg-gray-900/50">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Wallet className="w-12 h-12 text-gray-600 mb-4" />
|
||||
<h2 className="text-lg font-semibold text-white mb-2">
|
||||
{t('messaging.connectWallet', 'Connect Wallet')}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
{t('messaging.connectWalletDesc', 'Connect your wallet to use encrypted messaging.')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// API not ready
|
||||
if (!isPeopleReady) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 pt-20 sm:pt-[8.5rem]">
|
||||
<div className="max-w-2xl mx-auto px-4 py-12 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-green-500 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 pt-20 sm:pt-[8.5rem]">
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageSquare className="w-6 h-6 text-green-400" />
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-white">
|
||||
{t('messaging.title', 'PEZMessage')}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshInbox}
|
||||
disabled={loading}
|
||||
className="border-gray-700 text-gray-300"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
{isKeyRegistered && isKeyUnlocked && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setComposeOpen(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t('messaging.newMessage', 'New')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Setup Banner */}
|
||||
<div className="mb-4">
|
||||
<KeySetup
|
||||
isKeyRegistered={isKeyRegistered}
|
||||
isKeyUnlocked={isKeyUnlocked}
|
||||
registering={registering}
|
||||
onSetupKey={setupKey}
|
||||
onUnlockKey={unlockKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Era / Stats bar */}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500 mb-4 px-1">
|
||||
<span>Era {era}</span>
|
||||
<span className="text-gray-700">·</span>
|
||||
<span>
|
||||
{decryptedMessages.length} {t('messaging.messages', 'messages')}
|
||||
</span>
|
||||
<span className="text-gray-700">·</span>
|
||||
<span>{sendCount}/50 {t('messaging.sent', 'sent')}</span>
|
||||
</div>
|
||||
|
||||
{/* Inbox */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 text-green-500 animate-spin" />
|
||||
</div>
|
||||
) : decryptedMessages.length === 0 ? (
|
||||
<Card className="border-gray-800 bg-gray-900/30">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Inbox className="w-10 h-10 text-gray-700 mb-3" />
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('messaging.emptyInbox', 'No messages yet.')}
|
||||
</p>
|
||||
{isKeyRegistered && isKeyUnlocked && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => setComposeOpen(true)}
|
||||
className="text-green-400 mt-2"
|
||||
>
|
||||
{t('messaging.sendFirst', 'Send your first message')}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{decryptedMessages.map((msg, i) => (
|
||||
<InboxMessage
|
||||
key={`${msg.sender}-${msg.blockNumber}-${i}`}
|
||||
message={msg}
|
||||
currentBlock={currentBlock}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose Dialog */}
|
||||
<ComposeDialog
|
||||
open={composeOpen}
|
||||
onOpenChange={setComposeOpen}
|
||||
onSend={sendMessage}
|
||||
sending={sending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user