mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 13:37:59 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user