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:
2026-03-03 08:29:16 +03:00
parent 6aae238f05
commit ad3c0e414e
17 changed files with 1429 additions and 19 deletions
+184 -13
View File
@@ -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",
+3
View File
@@ -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",
+2
View File
@@ -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 />} />
+21 -5
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+319
View File
@@ -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,
};
}
+30
View File
@@ -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': 'جاري الإرسال...',
};
+30
View File
@@ -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': 'دەنێردرێت...',
};
+32 -1
View File
@@ -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...',
}
+30
View File
@@ -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': 'در حال ارسال...',
};
+30
View File
@@ -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...',
};
+31
View File
@@ -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...',
};
+88
View File
@@ -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();
}
+90
View File
@@ -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);
}
+201
View File
@@ -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>
);
}