diff --git a/Peercord Source/src/components/DMList.jsx b/Peercord Source/src/components/DMList.jsx
index d0b5e11..4c125ff 100644
--- a/Peercord Source/src/components/DMList.jsx
+++ b/Peercord Source/src/components/DMList.jsx
@@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react';
-import { ADMIN_PUBLIC_KEY } from '../p2p/index.js';
+import { network, ADMIN_PUBLIC_KEY } from '../p2p/index.js';
export default function DMList({ activeChannel, setActiveChannel, myKey, profile, unreadCounts, onOpenSettings, dms, servers, onlinePeers, typingUsers, activeCall, onReturnToCall, onOpenCreateGroup, onLeaveGroup, onDeleteGroup, isNetworkOnline }) {
const [now, setNow] = useState(Date.now());
- const[contextMenu, setContextMenu] = useState(null);
+ const [contextMenu, setContextMenu] = useState(null);
+ const [dmContextMenu, setDmContextMenu] = useState(null);
useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), 1000);
@@ -11,15 +12,41 @@ export default function DMList({ activeChannel, setActiveChannel, myKey, profile
},[]);
useEffect(() => {
- const handleClick = () => setContextMenu(null);
- if (contextMenu) document.addEventListener('click', handleClick);
+ const handleClick = () => {
+ setContextMenu(null);
+ setDmContextMenu(null);
+ };
+ if (contextMenu || dmContextMenu) document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
- },[contextMenu]);
+ },[contextMenu, dmContextMenu]);
- const acceptedDMs = Object.entries(dms).filter(([_, data]) => data.status === 'accepted');
+ // Filter out closed or blocked DMs from the main list
+ const acceptedDMs = Object.entries(dms).filter(([_, data]) => data.status === 'accepted' && data.isOpen !== false);
const pendingIncoming = Object.entries(dms).filter(([_, data]) => data.status === 'pending_incoming');
const groupChats = servers.filter(s => s.isGroupChat);
+ const handleCloseDM = (pubKey) => {
+ network.closeDM(pubKey);
+ if (activeChannel === pubKey) setActiveChannel('friends');
+ setDmContextMenu(null);
+ };
+
+ const handleRemoveFriend = (pubKey) => {
+ if (window.confirm("Are you sure you want to remove this contact?")) {
+ network.removeFriend(pubKey);
+ if (activeChannel === pubKey) setActiveChannel('friends');
+ setDmContextMenu(null);
+ }
+ };
+
+ const handleBlockUser = (pubKey) => {
+ if (window.confirm("Are you sure you want to block this user? You will no longer receive messages from them.")) {
+ network.blockUser(pubKey);
+ if (activeChannel === pubKey) setActiveChannel('friends');
+ setDmContextMenu(null);
+ }
+ };
+
const renderDM = (pubKey, data) => {
const isActive = activeChannel === pubKey;
const unread = unreadCounts[pubKey] || 0;
@@ -36,6 +63,10 @@ export default function DMList({ activeChannel, setActiveChannel, myKey, profile
setActiveChannel(pubKey)}
+ onContextMenu={(e) => {
+ e.preventDefault();
+ setDmContextMenu({ x: e.pageX, y: e.pageY, pubKey });
+ }}
className={`px-2 py-1.5 rounded cursor-pointer flex items-center justify-between group ${
isActive ? 'bg-panel text-text' : 'text-muted hover:bg-panel/50 hover:text-text'
}`}
@@ -149,6 +180,35 @@ export default function DMList({ activeChannel, setActiveChannel, myKey, profile
)}
+ {dmContextMenu && (
+ setDmContextMenu(null)} onContextMenu={(e) => { e.preventDefault(); setDmContextMenu(null); }}>
+
e.stopPropagation()}
+ >
+ handleCloseDM(dmContextMenu.pubKey)}
+ >
+ Close DM
+
+ handleRemoveFriend(dmContextMenu.pubKey)}
+ >
+ Remove Contact
+
+ handleBlockUser(dmContextMenu.pubKey)}
+ >
+ Block User
+
+
+
+ )}
+
{activeCall && (
data.status === 'accepted');
const pendingIncoming = Object.entries(dms).filter(([_, data]) => data.status === 'pending_incoming');
const pendingOutgoing = Object.entries(dms).filter(([_, data]) => data.status === 'pending_outgoing');
+ const blockedUsers = Object.entries(dms).filter(([_, data]) => data.status === 'blocked');
const handleAddFriend = async (e) => {
e.preventDefault();
@@ -33,6 +35,22 @@ export default function FriendsView({ dms }) {
}
};
+ const handleRemove = (pubKey) => {
+ if (window.confirm("Are you sure you want to remove this contact?")) {
+ network.removeFriend(pubKey);
+ }
+ };
+
+ const handleBlock = (pubKey) => {
+ if (window.confirm("Are you sure you want to block this user?")) {
+ network.blockUser(pubKey);
+ }
+ };
+
+ const handleUnblock = (pubKey) => {
+ network.removeFriend(pubKey); // Removing from block list resets them
+ };
+
return (
@@ -42,12 +60,24 @@ export default function FriendsView({ dms }) {
+ setActiveTab('all')}
+ className={`px-3 py-1 rounded font-medium text-sm transition-colors ${activeTab === 'all' ? 'bg-surface text-text' : 'text-muted hover:bg-surface/50 hover:text-text'}`}
+ >
+ All
+
setActiveTab('pending')}
className={`px-3 py-1 rounded font-medium text-sm transition-colors ${activeTab === 'pending' ? 'bg-surface text-text' : 'text-muted hover:bg-surface/50 hover:text-text'}`}
>
Pending {pendingIncoming.length > 0 && {pendingIncoming.length} }
+ setActiveTab('blocked')}
+ className={`px-3 py-1 rounded font-medium text-sm transition-colors ${activeTab === 'blocked' ? 'bg-surface text-text' : 'text-muted hover:bg-surface/50 hover:text-text'}`}
+ >
+ Blocked
+
setActiveTab('add')}
className={`px-3 py-1 rounded font-medium text-sm transition-colors ${activeTab === 'add' ? 'bg-accent text-white' : 'bg-accent/20 text-accent hover:bg-accent/30'}`}
@@ -57,11 +87,46 @@ export default function FriendsView({ dms }) {
-
+
+
+ {activeTab === 'all' && (
+
+
All Contacts — {allFriends.length}
+
+ {allFriends.map(([pubKey, data]) => (
+
+
+
+ {data.profile?.avatar ?
: data.profile?.displayName?.substring(0, 2).toUpperCase()}
+
+
+ {data.profile?.displayName}
+ @{data.profile?.username}
+
+
+
+
onNavigateToDM(pubKey)} className="w-9 h-9 rounded-full bg-surface flex items-center justify-center text-text hover:bg-accent hover:text-white transition-colors border border-panel" title="Message">
+
+
+
handleRemove(pubKey)} className="w-9 h-9 rounded-full bg-surface flex items-center justify-center text-red-500 hover:bg-red-500 hover:text-white transition-colors border border-panel" title="Remove">
+
+
+
handleBlock(pubKey)} className="w-9 h-9 rounded-full bg-surface flex items-center justify-center text-red-500 hover:bg-red-500 hover:text-white transition-colors border border-panel" title="Block">
+
+
+
+
+ ))}
+ {allFriends.length === 0 && (
+
You don't have any contacts yet.
+ )}
+
+
+ )}
+
{activeTab === 'pending' && (
Pending Requests — {pendingIncoming.length + pendingOutgoing.length}
-
{pendingIncoming.map(([pubKey, data]) => (
@@ -78,6 +143,9 @@ export default function FriendsView({ dms }) {
network.acceptDMRequest(pubKey)} className="w-9 h-9 rounded-full bg-surface flex items-center justify-center text-green-500 hover:bg-green-500 hover:text-white transition-colors border border-panel" title="Accept">
+
handleRemove(pubKey)} className="w-9 h-9 rounded-full bg-surface flex items-center justify-center text-red-500 hover:bg-red-500 hover:text-white transition-colors border border-panel" title="Decline">
+
+
))}
@@ -93,6 +161,11 @@ export default function FriendsView({ dms }) {
@{data.profile?.username} • Outgoing Contact Request
+
+ handleRemove(pubKey)} className="w-9 h-9 rounded-full bg-surface flex items-center justify-center text-red-500 hover:bg-red-500 hover:text-white transition-colors border border-panel" title="Cancel Request">
+
+
+
))}
@@ -103,6 +176,35 @@ export default function FriendsView({ dms }) {
)}
+ {activeTab === 'blocked' && (
+
+
Blocked Users — {blockedUsers.length}
+
+ {blockedUsers.map(([pubKey, data]) => (
+
+
+
+ {data.profile?.avatar ?
: data.profile?.displayName?.substring(0, 2).toUpperCase()}
+
+
+ {data.profile?.displayName}
+ @{data.profile?.username}
+
+
+
+ handleUnblock(pubKey)} className="px-4 py-1.5 rounded bg-surface text-text hover:bg-panel transition-colors border border-panel text-sm font-medium">
+ Unblock
+
+
+
+ ))}
+ {blockedUsers.length === 0 && (
+
You haven't blocked anyone.
+ )}
+
+
+ )}
+
{activeTab === 'add' && (
ADD CONTACT
diff --git a/Peercord Source/src/components/MainApp.jsx b/Peercord Source/src/components/MainApp.jsx
index f248955..c2c388e 100644
--- a/Peercord Source/src/components/MainApp.jsx
+++ b/Peercord Source/src/components/MainApp.jsx
@@ -142,6 +142,49 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
localStorage.setItem('pear_delivered_receipts', JSON.stringify(deliveredReceipts));
},[deliveredReceipts]);
+ // Secure P2P Account Sync Responder
+ useEffect(() => {
+ if (!myKey) return;
+ let syncSwarm;
+ try {
+ const b4a = window.require('b4a');
+ const sodium = window.require('sodium-native');
+ const Hyperswarm = window.require('hyperswarm');
+
+ syncSwarm = new Hyperswarm();
+ const syncTopic = b4a.alloc(32);
+ sodium.crypto_generichash(syncTopic, b4a.from('peercord-sync:' + myKey));
+
+ syncSwarm.on('connection', (conn) => {
+ conn.on('data', async (data) => {
+ try {
+ const msg = JSON.parse(b4a.toString(data));
+ if (msg.type === 'account_sync_request') {
+ const sigBuf = b4a.from(msg.signature, 'hex');
+ const msgBuf = b4a.from('sync-request:' + msg.tempKey);
+ const pubBuf = b4a.from(myKey, 'hex');
+
+ if (sodium.crypto_sign_verify_detached(sigBuf, msgBuf, pubBuf)) {
+ console.log("[Sync] Valid sync request received. Exporting account...");
+ const exportData = await network.exportAccount();
+ const payload = b4a.from(JSON.stringify({ type: 'account_sync_reply', data: exportData }));
+ conn.write(payload);
+ }
+ }
+ } catch (e) {}
+ });
+ });
+
+ syncSwarm.join(syncTopic, { server: true, client: false });
+ } catch (e) {
+ console.error("Failed to start sync swarm", e);
+ }
+
+ return () => {
+ if (syncSwarm) syncSwarm.destroy();
+ };
+ }, [myKey]);
+
useEffect(() => {
if (!initialized.current && typeof window !== 'undefined') {
initialized.current = true;
@@ -706,7 +749,7 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
{/* Chat Area (Hidden if CallView is active) */}
{activeView === 'dms' && activeDm === 'friends' ? (
-
+
) : (
{
+ try {
+ const data = await network.exportAccount();
+ const blob = new Blob([data], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `peercord-backup-${profile.username}-${Date.now()}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch (err) {
+ alert("Failed to export account backup.");
+ console.error(err);
+ }
+ };
+
const handleWipeData = async () => {
if (window.confirm("WARNING: Are you absolutely sure you want to wipe all data? \n\nThis will permanently delete your identity, messages, contacts, and hubs you've joined or created. The app will close immediately after. This cannot be undone!")) {
await network.wipeAllData();
@@ -420,11 +436,11 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
-
Account Seed (Private Key)
+
Account Backup & Recovery
- This 64-character seed is the only way to log back into your account if you switch devices. Do not share it with anyone!
+ You can export your entire account (including all chat history, hubs, and settings) to a single file, or copy your raw seed key.
-
+
+
+
+ Export Full Account Backup (.json)
+
diff --git a/Peercord Source/src/components/SetupScreen.jsx b/Peercord Source/src/components/SetupScreen.jsx
index 7cd29bb..bcaa9f2 100644
--- a/Peercord Source/src/components/SetupScreen.jsx
+++ b/Peercord Source/src/components/SetupScreen.jsx
@@ -11,6 +11,8 @@ export default function SetupScreen({ setProfile }) {
const [seedHex, setSeedHex] = useState('');
const [isChecking, setIsChecking] = useState(false);
const [seedAcknowledged, setSeedAcknowledged] = useState(false);
+ const [syncStatus, setSyncStatus] = useState('');
+ const fileInputRef = useRef(null);
useEffect(() => {
const accounts = JSON.parse(localStorage.getItem('pear_saved_accounts') || '[]');
@@ -57,16 +59,78 @@ export default function SetupScreen({ setProfile }) {
setProfile(profile);
};
- const handleLogin = (e) => {
+ const handleSeedRestore = async (e) => {
e.preventDefault();
- if (!seedHex.trim() || !displayName.trim() || !username.trim()) return;
+ if (!seedHex.trim()) return;
- const cleanUsername = username.trim().toLowerCase().replace(/[^a-z0-9_.]/g, '');
- const profile = { displayName: displayName.trim(), username: cleanUsername, seedHex: seedHex.trim(), avatar: null };
+ setIsChecking(true);
+ setSyncStatus('Looking for your online devices...');
- saveAccountToStorage(profile);
- localStorage.setItem('pear_discord_identity', JSON.stringify(profile));
- setProfile(profile);
+ try {
+ const b4a = window.require('b4a');
+ const sodium = window.require('sodium-native');
+ const Hyperswarm = window.require('hyperswarm');
+
+ const seedBuf = b4a.from(seedHex.trim(), 'hex');
+ const realPubKey = b4a.alloc(32);
+ const realSecKey = b4a.alloc(64);
+ sodium.crypto_sign_seed_keypair(realPubKey, realSecKey, seedBuf);
+ const realPubKeyHex = b4a.toString(realPubKey, 'hex');
+
+ const tempSwarm = new Hyperswarm();
+ const syncTopic = b4a.alloc(32);
+ sodium.crypto_generichash(syncTopic, b4a.from('peercord-sync:' + realPubKeyHex));
+
+ let synced = false;
+
+ tempSwarm.on('connection', (conn) => {
+ const tempKeyHex = b4a.toString(tempSwarm.keyPair.publicKey, 'hex');
+ const msgBuf = b4a.from('sync-request:' + tempKeyHex);
+ const sigBuf = b4a.alloc(sodium.crypto_sign_BYTES);
+ sodium.crypto_sign_detached(sigBuf, msgBuf, realSecKey);
+
+ conn.write(b4a.from(JSON.stringify({
+ type: 'account_sync_request',
+ tempKey: tempKeyHex,
+ signature: b4a.toString(sigBuf, 'hex')
+ })));
+
+ conn.on('data', async (data) => {
+ try {
+ const msg = JSON.parse(b4a.toString(data));
+ if (msg.type === 'account_sync_reply' && msg.data) {
+ setSyncStatus('Syncing data...');
+ const importedProfile = await network.importAccount(msg.data);
+ synced = true;
+ tempSwarm.destroy();
+
+ saveAccountToStorage(importedProfile);
+ localStorage.setItem('pear_discord_identity', JSON.stringify(importedProfile));
+ setProfile(importedProfile);
+ }
+ } catch (err) {
+ console.error("Sync parse error", err);
+ }
+ });
+ });
+
+ tempSwarm.join(syncTopic, { client: true, server: false });
+
+ setTimeout(() => {
+ if (!synced) {
+ tempSwarm.destroy();
+ setIsChecking(false);
+ setSyncStatus('');
+ alert("Could not find any of your devices online to sync from.\n\nPlease ensure your other device is open and connected to the internet, or use a Backup File (.json) instead.");
+ }
+ }, 15000);
+
+ } catch (err) {
+ console.error(err);
+ setIsChecking(false);
+ setSyncStatus('');
+ alert("Invalid seed or network error.");
+ }
};
const handleSavedLogin = (profile) => {
@@ -74,6 +138,27 @@ export default function SetupScreen({ setProfile }) {
setProfile(profile);
};
+ const handleImportAccount = (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = async (event) => {
+ try {
+ const jsonString = event.target.result;
+ const importedProfile = await network.importAccount(jsonString);
+
+ saveAccountToStorage(importedProfile);
+ localStorage.setItem('pear_discord_identity', JSON.stringify(importedProfile));
+ setProfile(importedProfile);
+ } catch (err) {
+ alert("Failed to import account. The backup file may be corrupted or invalid.");
+ console.error(err);
+ }
+ };
+ reader.readAsText(file);
+ };
+
return (
)}
@@ -174,49 +263,36 @@ export default function SetupScreen({ setProfile }) {
)}
{view === 'login' && (
-