Lotta shit ngl
This commit is contained in:
0% [█ █ █ █ █ █ █ █ █ █] 100% 2026-06-16 15:25:36 -05:00
parent 2689c5336c
commit 439791ae4d
10 changed files with 594 additions and 134 deletions

View File

@ -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
<div
key={pubKey}
onClick={() => 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
</div>
)}
{dmContextMenu && (
<div className="fixed inset-0 z-50" onClick={() => setDmContextMenu(null)} onContextMenu={(e) => { e.preventDefault(); setDmContextMenu(null); }}>
<div
className="absolute bg-panel border border-surface shadow-xl rounded py-1.5 w-40 flex flex-col"
style={{ top: dmContextMenu.y, left: dmContextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
<button
className="w-full text-left px-3 py-1.5 text-sm text-text hover:bg-accent hover:text-white transition-colors"
onClick={() => handleCloseDM(dmContextMenu.pubKey)}
>
Close DM
</button>
<button
className="w-full text-left px-3 py-1.5 text-sm text-red-500 hover:bg-red-500 hover:text-white transition-colors"
onClick={() => handleRemoveFriend(dmContextMenu.pubKey)}
>
Remove Contact
</button>
<button
className="w-full text-left px-3 py-1.5 text-sm text-red-500 hover:bg-red-500 hover:text-white transition-colors"
onClick={() => handleBlockUser(dmContextMenu.pubKey)}
>
Block User
</button>
</div>
</div>
)}
{activeCall && (
<div
onClick={onReturnToCall}

View File

@ -1,13 +1,15 @@
import React, { useState } from 'react';
import { network } from '../p2p/index.js';
export default function FriendsView({ dms }) {
const [activeTab, setActiveTab] = useState('pending');
export default function FriendsView({ dms, onNavigateToDM }) {
const [activeTab, setActiveTab] = useState('all');
const [searchUsername, setSearchUsername] = useState('');
const[searchStatus, setSearchStatus] = useState(''); // 'searching', 'queued', 'found', 'error'
const [searchStatus, setSearchStatus] = useState('');
const allFriends = Object.entries(dms).filter(([_, data]) => 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 (
<div className="flex-1 flex flex-col bg-base min-w-0">
<div className="h-14 shadow-sm flex items-center px-4 border-b border-surface gap-6 shrink-0 bg-panel z-10">
@ -42,12 +60,24 @@ export default function FriendsView({ dms }) {
</div>
<div className="w-[1px] h-6 bg-surface"></div>
<div className="flex gap-4">
<button
onClick={() => 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
</button>
<button
onClick={() => 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 && <span className="bg-red-500 text-white text-xs px-1.5 rounded-full ml-1">{pendingIncoming.length}</span>}
</button>
<button
onClick={() => 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
</button>
<button
onClick={() => 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 }) {
</div>
</div>
<div className="flex-1 p-6 overflow-y-auto">
<div className="flex-1 p-6 overflow-y-auto custom-scrollbar">
{activeTab === 'all' && (
<div>
<h2 className="text-xs font-bold text-muted uppercase mb-4">All Contacts {allFriends.length}</h2>
<div className="space-y-2">
{allFriends.map(([pubKey, data]) => (
<div key={pubKey} className="flex items-center justify-between p-3 hover:bg-panel rounded-lg border-t border-surface group transition-colors">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-md bg-indigo-500 flex items-center justify-center text-white font-bold overflow-hidden">
{data.profile?.avatar ? <img src={data.profile.avatar} className="w-full h-full object-cover"/> : data.profile?.displayName?.substring(0, 2).toUpperCase()}
</div>
<div className="flex flex-col">
<span className="text-text font-bold">{data.profile?.displayName}</span>
<span className="text-xs text-muted">@{data.profile?.username}</span>
</div>
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => 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">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
</button>
<button onClick={() => 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">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
<button onClick={() => 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">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line></svg>
</button>
</div>
</div>
))}
{allFriends.length === 0 && (
<div className="text-center text-muted mt-10">You don't have any contacts yet.</div>
)}
</div>
</div>
)}
{activeTab === 'pending' && (
<div>
<h2 className="text-xs font-bold text-muted uppercase mb-4">Pending Requests {pendingIncoming.length + pendingOutgoing.length}</h2>
<div className="space-y-2">
{pendingIncoming.map(([pubKey, data]) => (
<div key={pubKey} className="flex items-center justify-between p-3 hover:bg-panel rounded-lg border-t border-surface group transition-colors">
@ -78,6 +143,9 @@ export default function FriendsView({ dms }) {
<button onClick={() => 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">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</button>
<button onClick={() => 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">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
</div>
))}
@ -93,6 +161,11 @@ export default function FriendsView({ dms }) {
<span className="text-xs text-muted">@{data.profile?.username} Outgoing Contact Request</span>
</div>
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => 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">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
</div>
))}
@ -103,6 +176,35 @@ export default function FriendsView({ dms }) {
</div>
)}
{activeTab === 'blocked' && (
<div>
<h2 className="text-xs font-bold text-muted uppercase mb-4">Blocked Users {blockedUsers.length}</h2>
<div className="space-y-2">
{blockedUsers.map(([pubKey, data]) => (
<div key={pubKey} className="flex items-center justify-between p-3 hover:bg-panel rounded-lg border-t border-surface group transition-colors">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-md bg-surface flex items-center justify-center text-muted font-bold overflow-hidden border border-panel">
{data.profile?.avatar ? <img src={data.profile.avatar} className="w-full h-full object-cover"/> : data.profile?.displayName?.substring(0, 2).toUpperCase()}
</div>
<div className="flex flex-col">
<span className="text-text font-bold">{data.profile?.displayName}</span>
<span className="text-xs text-muted">@{data.profile?.username}</span>
</div>
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => 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
</button>
</div>
</div>
))}
{blockedUsers.length === 0 && (
<div className="text-center text-muted mt-10">You haven't blocked anyone.</div>
)}
</div>
</div>
)}
{activeTab === 'add' && (
<div className="max-w-2xl">
<h2 className="text-text font-bold mb-2">ADD CONTACT</h2>

View File

@ -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) */}
<div className={`flex-1 flex flex-col min-w-0 transition-[margin] duration-300 ${showCallView ? 'hidden' : ''} ${isDrawerOpen && !isPinned ? 'mr-64' : ''}`}>
{activeView === 'dms' && activeDm === 'friends' ? (
<FriendsView dms={dms} />
<FriendsView dms={dms} onNavigateToDM={handleNavigateToDM} />
) : (
<ChatArea
activeView={activeView}

View File

@ -267,6 +267,22 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
if (profile.seedHex) navigator.clipboard.writeText(profile.seedHex);
};
const handleExportAccount = async () => {
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,
</div>
<div className="bg-surface rounded-lg p-6 mb-6 border border-yellow-900/50">
<h3 className="text-yellow-500 font-bold mb-2 uppercase text-xs">Account Seed (Private Key)</h3>
<h3 className="text-yellow-500 font-bold mb-2 uppercase text-xs">Account Backup & Recovery</h3>
<p className="text-muted text-sm mb-4">
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.
</p>
<div className="flex gap-2">
<div className="flex gap-2 mb-4">
<input
type={showSeed ? "text" : "password"}
value={profile.seedHex}
@ -438,6 +454,10 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
Copy
</button>
</div>
<button onClick={handleExportAccount} className="w-full bg-panel hover:bg-base text-text font-bold py-2.5 rounded transition-colors border border-surface flex items-center justify-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
Export Full Account Backup (.json)
</button>
</div>
<div className="flex justify-end gap-3">

View File

@ -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 (
<div className="flex h-full w-full items-center justify-center bg-base font-sans">
<div className="bg-surface p-8 rounded-lg shadow-xl w-96 flex flex-col items-center border border-panel">
@ -107,8 +192,12 @@ export default function SetupScreen({ setProfile }) {
Create New Account
</button>
<button onClick={() => setView('login')} className="w-full bg-panel hover:bg-base text-text font-bold py-2.5 rounded transition-colors border border-surface">
Login with Account Seed
Restore Account from Seed
</button>
<button onClick={() => fileInputRef.current?.click()} className="w-full bg-transparent hover:underline text-muted font-bold py-2.5 rounded transition-colors text-sm mt-2">
Restore from Backup File
</button>
<input type="file" accept=".json" ref={fileInputRef} onChange={handleImportAccount} className="hidden" />
</div>
</div>
)}
@ -174,49 +263,36 @@ export default function SetupScreen({ setProfile }) {
)}
{view === 'login' && (
<form onSubmit={handleLogin} className="w-full">
<h1 className="text-2xl font-bold text-text mb-2 text-center">Login with Seed</h1>
<p className="text-muted text-sm text-center mb-6">Paste your 64-character private key to restore your account.</p>
<form onSubmit={handleSeedRestore} className="w-full">
<h1 className="text-2xl font-bold text-text mb-2 text-center">Restore Account</h1>
<p className="text-muted text-sm text-center mb-6">Paste your 64-character private key to securely sync your account from another online device.</p>
<div className="mb-4">
<div className="mb-6">
<label className="block text-xs font-bold text-muted uppercase mb-2">Account Seed (Private Key)</label>
<input
type="password"
value={seedHex}
onChange={(e) => setSeedHex(e.target.value)}
className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-2 focus:ring-accent font-mono text-sm"
disabled={isChecking}
className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-2 focus:ring-accent font-mono text-sm disabled:opacity-50"
placeholder="Paste 64-character hex seed..."
/>
</div>
<div className="mb-4">
<label className="block text-xs font-bold text-muted uppercase mb-2">Display Name</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-2 focus:ring-accent"
placeholder="e.g. Satoshi"
maxLength={24}
/>
</div>
<div className="mb-6">
<label className="block text-xs font-bold text-muted uppercase mb-2">Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_.]/g, ''))}
className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-2 focus:ring-accent"
placeholder="e.g. satoshi_nakamoto"
maxLength={24}
/>
</div>
<button type="submit" className="w-full bg-accent hover:opacity-90 text-white font-bold py-3 rounded transition-opacity">
Restore & Login
<button type="submit" disabled={isChecking || !seedHex.trim()} className="w-full bg-accent hover:opacity-90 text-white font-bold py-3 rounded transition-opacity disabled:opacity-50 flex justify-center items-center gap-2">
{isChecking ? (
<>
<span className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
{syncStatus}
</>
) : (
'Sync & Login'
)}
</button>
<div className="mt-4 flex justify-between text-sm">
<button type="button" onClick={() => setView('signup')} className="text-accent hover:underline">Create Account</button>
{savedAccounts.length > 0 && <button type="button" onClick={() => setView('saved')} className="text-muted hover:underline">Saved Accounts</button>}
<button type="button" onClick={() => setView('signup')} disabled={isChecking} className="text-accent hover:underline disabled:opacity-50">Create Account</button>
{savedAccounts.length > 0 && <button type="button" onClick={() => setView('saved')} disabled={isChecking} className="text-muted hover:underline disabled:opacity-50">Saved Accounts</button>}
</div>
</form>
)}

View File

@ -15,7 +15,7 @@ export async function handleData(network, peerKey, data, conn) {
handleWhoisReply(network, parsed);
break;
case 'ephemeral':
handleEphemeral(network, peerKey, parsed);
handleEphemeral(network, peerKey, parsed, conn);
break;
default:
// Could be a standard message core, which is handled by replication, not this handler.
@ -114,10 +114,32 @@ function handleWhoisReply(network, parsed) {
network._checkPendingRequests(parsed.username, parsed.pubKey, parsed.profile);
}
function handleEphemeral(network, peerKey, parsed) {
function handleEphemeral(network, peerKey, parsed, conn) {
const { payload } = parsed;
if (!payload) return;
if (payload.type === 'sync_request') {
if (network.joinedTopics.has(payload.topic)) {
const pendingTargets = Object.entries(network.dms)
.filter(([_, data]) => data.status === 'pending_outgoing')
.map(([key]) => key);
const identityMsg = JSON.stringify({
type: 'identity',
displayName: network.displayName,
username: network.username,
avatar: network.avatar,
bio: network.bio,
connections: network.connections,
coreKey: network.coreKey,
topics: Array.from(network.joinedTopics),
pendingTargets
});
try { conn.write(b4a.from(identityMsg)); } catch(e) {}
}
return;
}
if (payload.type === 'offline') {
const peerInfo = network.peers.get(peerKey);
if (peerInfo) {

View File

@ -21,6 +21,7 @@ class P2PNetwork {
this.coresDb = null;
this.profilesDb = null;
this.seedHex = null;
this.coreKey = null;
this.myKey = null;
this.secretKey = null;
@ -46,15 +47,13 @@ class P2PNetwork {
this.joinedTopics = new Set();
this.syncTimeout = null;
this._msgTimeout = null;
this._identityTimeout = null;
this.transfers = {};
this.webrtcListeners = new Set();
// Distributed Systems Ordering
this.logicalClock = 0;
this.timeOffset = 0;
// App State Tracking
this.activeCalls = 0;
this.onInit = null;
@ -69,7 +68,6 @@ class P2PNetwork {
this.onTransfersUpdate = null;
}
// Method Bindings
getAllMessages = () => getAllMessages(this);
processMessage = (msg) => processMessage(this, msg);
sendDMRequest = (targetKey, profile) => sendDMRequest(this, targetKey, profile);
@ -145,7 +143,6 @@ class P2PNetwork {
if (this.onTransfersUpdate) this.onTransfersUpdate(this.transfers);
this._emitMessages();
this._emitServers();
this._emitServerMembers();
};
@ -175,6 +172,97 @@ class P2PNetwork {
updateProfile = (name, avatar, username, bio, connections) => Identity.updateProfile(this, name, avatar, username, bio, connections);
async closeDM(targetKey) {
if (this.dms[targetKey]) {
this.dms[targetKey].isOpen = false;
await this.db.put('dm:' + targetKey, this.dms[targetKey]);
if (this.onDMsUpdate) this.onDMsUpdate({ ...this.dms });
}
}
async removeFriend(targetKey) {
if (this.dms[targetKey]) {
delete this.dms[targetKey];
await this.db.del('dm:' + targetKey);
if (this.onDMsUpdate) this.onDMsUpdate({ ...this.dms });
}
}
async blockUser(targetKey) {
if (this.dms[targetKey]) {
this.dms[targetKey].status = 'blocked';
this.dms[targetKey].isOpen = false;
await this.db.put('dm:' + targetKey, this.dms[targetKey]);
if (this.onDMsUpdate) this.onDMsUpdate({ ...this.dms });
} else {
this.dms[targetKey] = { status: 'blocked', isOpen: false, profile: this.knownProfiles.get(targetKey) || { displayName: 'Unknown' } };
await this.db.put('dm:' + targetKey, this.dms[targetKey]);
if (this.onDMsUpdate) this.onDMsUpdate({ ...this.dms });
}
}
async exportAccount() {
const exportData = {
profile: {
displayName: this.displayName,
username: this.username,
avatar: this.avatar,
bio: this.bio,
connections: this.connections,
seedHex: this.seedHex
},
dms: this.dms,
servers: this.servers,
knownProfiles: Array.from(this.knownProfiles.entries()),
userDirectory: Array.from(this.userDirectory.entries()),
settings: {
theme: localStorage.getItem('peercord_theme'),
audioInput: localStorage.getItem('pear_audio_input'),
audioOutput: localStorage.getItem('pear_audio_output'),
videoInput: localStorage.getItem('pear_video_input'),
autoRestart: localStorage.getItem('pear_auto_restart'),
liveDecryption: localStorage.getItem('pear_live_decryption'),
ircMode: localStorage.getItem('pear_irc_mode'),
noiseSuppression: localStorage.getItem('pear_noise_suppression'),
closeToTray: localStorage.getItem('pear_close_to_tray'),
pinMembers: localStorage.getItem('pear_pin_members'),
notifyDMs: localStorage.getItem('pear_notify_dms'),
notifyHubs: localStorage.getItem('pear_notify_hubs'),
notifyMentions: localStorage.getItem('pear_notify_mentions'),
notifyCalls: localStorage.getItem('pear_notify_calls')
}
};
return JSON.stringify(exportData);
}
async importAccount(jsonString) {
const data = JSON.parse(jsonString);
for (const [key, value] of Object.entries(data.dms)) {
await this.db.put('dm:' + key, value);
}
for (const server of data.servers) {
await this.serverDb.put(server.topicHex, server);
}
for (const [key, value] of data.knownProfiles) {
await this.profilesDb.put(key, value);
}
for (const [key, value] of data.userDirectory) {
await this.dirDb.put(key, value);
}
if (data.settings) {
for (const [k, v] of Object.entries(data.settings)) {
if (v !== null && v !== undefined) {
const storageKey = k === 'theme' ? 'peercord_theme' : `pear_${k.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`)}`;
localStorage.setItem(storageKey, v);
}
}
}
return data.profile;
}
_checkPendingRequests = (uname, pubKey, profile) => {
if (this.pendingFriendRequests.has(uname)) {
this.pendingFriendRequests.delete(uname);
@ -225,7 +313,7 @@ class P2PNetwork {
activeDownloads++;
}
} else if (t.state === 'uploading') {
if (t.speed > 0 || (t.progress > 0 && t.progress < 1)) {
if (t.progress < 1) {
activeUploads++;
}
}
@ -310,7 +398,9 @@ class P2PNetwork {
if (!this.swarm) return;
console.log("[P2P] Network online event detected. Reconnecting...");
try {
await this.swarm.flush();
// Hyperswarm handles reconnections automatically.
// Forcing a non-blocking flush is enough to kickstart the DHT without causing a UDP flood.
this.swarm.flush().catch(()=>{});
} catch (e) {
console.warn("[P2P] Reconnect flush failed:", e);
}
@ -342,6 +432,7 @@ class P2PNetwork {
}
async initialize(seedHex, displayName, username, avatar = null, bio = '', connections = []) {
this.seedHex = seedHex;
this.displayName = displayName;
this.username = (username || 'unknown').toLowerCase();
this.avatar = avatar;
@ -401,7 +492,12 @@ class P2PNetwork {
const profilesDbCore = this.store.get({ name: 'profiles-db' }); await profilesDbCore.ready();
this.profilesDb = new Hyperbee(profilesDbCore, { keyEncoding: 'utf-8', valueEncoding: 'json' }); await this.profilesDb.ready();
for await (const { key, value } of this.db.createReadStream({ gt: 'dm:', lt: 'dm:~' })) { this.dms[key.split(':')[1]] = value; if (value.profile) this.knownProfiles.set(key.split(':')[1], value.profile); }
for await (const { key, value } of this.db.createReadStream({ gt: 'dm:', lt: 'dm:~' })) {
const pubKey = key.split(':')[1];
if (value.isOpen === undefined) value.isOpen = true;
this.dms[pubKey] = value;
if (value.profile) this.knownProfiles.set(pubKey, value.profile);
}
for await (const { key, value } of this.serverDb.createReadStream()) { this.servers.push({ topicHex: key, ...value }); }
for await (const { key, value } of this.dirDb.createReadStream()) { this.userDirectory.set(key, value); if (value.pubKey && value.profile) this.knownProfiles.set(value.pubKey, value.profile); }
for await (const { key } of this.pendingRequestsDb.createReadStream()) { this.pendingFriendRequests.add(key); }
@ -418,11 +514,35 @@ class P2PNetwork {
this.secretKey = secretKey;
this.knownProfiles.set(this.myKey, { displayName: this.displayName, username: this.username, avatar: this.avatar, bio: this.bio, connections: this.connections });
// EMIT IMMEDIATELY BEFORE SWARM JOINS TO PREVENT UI BLOCKING
this._emitKnownProfiles();
if (this.onInit) this.onInit(this.myKey);
if (this.onDMsUpdate) this.onDMsUpdate({ ...this.dms });
this._emitServers();
for (let i = 0; i < this.localCore.length; i++) { this.processMessage(await this.localCore.get(i)); }
this._emitMessages();
const corePromises =[];
for await (const { key, value } of this.coresDb.createReadStream()) {
corePromises.push(this.trackPeerCore(value));
}
await Promise.all(corePromises);
// SETUP SWARM
this.swarm = new Hyperswarm({ keyPair: { publicKey, secretKey } });
this.swarm.on('connection', (conn, info) => {
conn.on('error', () => {}); // Prevent ECONNRESET crashes
this.store.replicate(conn);
const peerKey = b4a.toString(info.publicKey, 'hex');
// Preserve existing peer info if connection multiplexes
const existingPeer = this.peers.get(peerKey);
if (existingPeer) {
existingPeer.conn = conn;
} else {
this.peers.set(peerKey, { conn, displayName: 'Unknown', username: 'unknown', avatar: null, bio: '', connections: [], coreKey: null });
}
const pendingTargets = Object.entries(this.dms)
.filter(([_, data]) => data.status === 'pending_outgoing')
@ -442,68 +562,50 @@ class P2PNetwork {
conn.write(b4a.from(identityMsg));
if (this.onPeerUpdate) this.onPeerUpdate(this.getPeerList());
conn.on('data', async (data) => handleData(this, peerKey, data, conn));
conn.on('close', () => {
// Only delete if this specific connection is still the active one
const currentPeer = this.peers.get(peerKey);
if (currentPeer && currentPeer.conn === conn) {
this.peers.delete(peerKey);
if (this.onPeerUpdate) this.onPeerUpdate(this.getPeerList());
}
});
});
const paceJoin = () => new Promise(resolve => setTimeout(resolve, 100));
let joinCount = 0;
// BACKGROUND JOINS TO PREVENT UDP FLOOD / NAT EXHAUSTION
(async () => {
const pace = () => new Promise(r => setTimeout(r, 400)); // 400ms between DHT lookups
if (this.username && this.username !== 'unknown') {
const myTopic = b4a.alloc(32);
sodium.crypto_generichash(myTopic, b4a.from('peercord-user:' + this.username));
this.swarm.join(myTopic, { client: false, server: true });
joinCount++;
await pace();
}
for (const uname of this.pendingFriendRequests) {
const topic = b4a.alloc(32);
sodium.crypto_generichash(topic, b4a.from('peercord-user:' + uname));
this.swarm.join(topic, { client: true, server: false });
joinCount++;
if (joinCount % 5 === 0) {
try { await this.swarm.flush(); } catch(e) {}
} else {
await paceJoin();
}
await pace();
}
for (const server of this.servers) {
await this._joinTopic(server.topicHex, true);
joinCount++;
if (joinCount % 5 === 0) {
try { await this.swarm.flush(); } catch(e) {}
} else {
await paceJoin();
}
await pace();
}
const globalUpdateTopic = b4a.alloc(32);
sodium.crypto_generichash(globalUpdateTopic, b4a.from('peercord-global-updates'));
this.swarm.join(globalUpdateTopic, { client: true, server: true });
for (let i = 0; i < this.localCore.length; i++) { this.processMessage(await this.localCore.get(i)); }
this._emitKnownProfiles();
if (this.onInit) this.onInit(this.myKey);
if (this.onDMsUpdate) this.onDMsUpdate({ ...this.dms });
this._emitServers();
this._emitMessages();
const corePromises =[];
for await (const { key, value } of this.coresDb.createReadStream()) {
corePromises.push(this.trackPeerCore(value));
}
await Promise.all(corePromises);
this.swarm.flush().then(() => {
console.log("[P2P] Swarm flushed and announced.");
}).catch(err => console.warn("[P2P] Swarm flush failed (offline?):", err));
}).catch(err => console.warn("[P2P] Swarm flush failed:", err));
})();
}
getPeerList() {
@ -519,10 +621,18 @@ class P2PNetwork {
const topic = b4a.from(topicHex, 'hex');
this.swarm.join(topic, { client: true, server: true });
// Debounce identity broadcast to prevent TCP floods when joining many topics
if (this._identityTimeout) clearTimeout(this._identityTimeout);
this._identityTimeout = setTimeout(() => {
this._broadcastIdentity();
}, 1000);
// Request sync from existing members to fetch history immediately
this.sendEphemeral({ type: 'sync_request', topic: topicHex });
if (!skipFlush) {
try { await this.swarm.flush(); } catch(e) {}
// Don't await flush, it blocks the caller.
this.swarm.flush().catch(()=>{});
}
}
@ -556,6 +666,7 @@ class P2PNetwork {
this.transfers = {};
this.webrtcListeners.clear();
if (this._msgTimeout) clearTimeout(this._msgTimeout);
if (this._identityTimeout) clearTimeout(this._identityTimeout);
}
async wipeAllData() {

View File

@ -43,7 +43,7 @@ export async function searchUser(network, targetUsername) {
const msg = b4a.from(JSON.stringify({ type: 'whois', queryId, username: normalized }));
for (const { conn } of network.peers.values()) {
conn.write(msg);
try { conn.write(msg); } catch(e) {}
}
});
}
@ -85,14 +85,13 @@ export async function trackPeerCore(network, coreKeyHex) {
core.on('append', async () => {
network._emitSync();
for (let i = processedSeq + 1; i < core.length; i++) {
if (core.has(i)) {
// Force download of the new block by awaiting core.get without checking core.has
const msg = await core.get(i);
network.processMessage(msg);
processedSeq = Math.max(processedSeq, i);
}
}
});
// Tell the core to download all blocks in the background
core.download({ start: 0, end: core.length });
// Tell the core to download all blocks continuously in the background
core.download();
}

View File

@ -36,7 +36,6 @@ export function getAllMessages(network) {
}
}
} else if (ch.length === 64) {
// Group chat: Filter out messages for group chats we are not in
if (!joinedTopics.has(ch)) return false;
}
}
@ -134,7 +133,7 @@ export async function processMessage(network, msg) {
if (msg.payload.type === 'dm_request' && msg.sender !== network.myKey) {
if (!network.dms[msg.sender]) {
network.dms[msg.sender] = { status: 'pending_incoming', profile: msg.payload.profile };
network.dms[msg.sender] = { status: 'pending_incoming', profile: msg.payload.profile, isOpen: true };
await network.db.put('dm:' + msg.sender, network.dms[msg.sender]);
network.knownProfiles.set(msg.sender, msg.payload.profile);
network._emitKnownProfiles();
@ -143,6 +142,7 @@ export async function processMessage(network, msg) {
} else if (msg.payload.type === 'dm_accept' && msg.sender !== network.myKey) {
if (network.dms[msg.sender] && network.dms[msg.sender].status === 'pending_outgoing') {
network.dms[msg.sender].status = 'accepted';
network.dms[msg.sender].isOpen = true;
await network.db.put('dm:' + msg.sender, network.dms[msg.sender]);
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
}
@ -151,6 +151,13 @@ export async function processMessage(network, msg) {
network.messages.set(msg.payload.id, msg);
network._emitMessages();
// Re-open DM if it was closed
if (network.dms[msg.sender] && !network.dms[msg.sender].isOpen && network.dms[msg.sender].status !== 'blocked') {
network.dms[msg.sender].isOpen = true;
network.db.put('dm:' + msg.sender, network.dms[msg.sender]);
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
}
if (msg.payload.type === 'file') {
network._downloadFile(msg.payload.id, msg.payload.file, msg.sender === network.myKey);
}
@ -215,6 +222,21 @@ export async function processMessage(network, msg) {
if (type === 'server_join') {
if (!network.serverMembers[serverTopicHex]) network.serverMembers[serverTopicHex] = new Set();
network.serverMembers[serverTopicHex].add(msg.sender);
// Auto-assign default Members role
const server = network.servers.find(s => s.topicHex === serverTopicHex);
if (server) {
const membersRole = server.roles?.find(r => r.id === 'role_members');
if (membersRole) {
if (!server.memberRoles) server.memberRoles = {};
if (!server.memberRoles[msg.sender]) server.memberRoles[msg.sender] = [];
if (!server.memberRoles[msg.sender].includes(membersRole.id)) {
server.memberRoles[msg.sender].push(membersRole.id);
network.serverDb.put(serverTopicHex, server);
}
}
}
network._emitServerMembers();
return;
}
@ -407,7 +429,6 @@ export async function processMessage(network, msg) {
}
}
} else if (channel && channel.length === 64) {
// Group chat: Only accept if we are actually in this group chat
const gc = network.servers.find(s => s.topicHex === channel && s.isGroupChat);
if (gc) {
canAccept = true;
@ -471,7 +492,7 @@ export async function _appendEncryptedMessage(network, targetKey, payloadObj) {
}
export async function sendDMRequest(network, targetKey, profile) {
network.dms[targetKey] = { status: 'pending_outgoing', profile };
network.dms[targetKey] = { status: 'pending_outgoing', profile, isOpen: true };
await network.db.put('dm:' + targetKey, network.dms[targetKey]);
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
await _appendEncryptedMessage(network, targetKey, { type: 'dm_request', profile: { displayName: network.displayName, username: network.username, avatar: network.avatar, bio: network.bio, connections: network.connections } });
@ -481,6 +502,7 @@ export async function sendDMRequest(network, targetKey, profile) {
export async function acceptDMRequest(network, targetKey) {
if (network.dms[targetKey]) {
network.dms[targetKey].status = 'accepted';
network.dms[targetKey].isOpen = true;
await network.db.put('dm:' + targetKey, network.dms[targetKey]);
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
}

View File

@ -6,7 +6,12 @@ export async function createServer(network, name, icon, allowAnyoneToInvite, isG
sodium.randombytes_buf(topic);
const topicHex = b4a.toString(topic, 'hex');
const channels = { text: ['general-chat'], voice: ['general-voice'], permissions: {}, send_permissions: {} };
const channels = {
text: ['general-chat'],
voice: ['general-voice'],
permissions: { 'general-chat': ['role_members'], 'general-voice': ['role_members'] },
send_permissions: { 'general-chat': ['role_members'], 'general-voice': ['role_members'] }
};
// Default roles and permissions setup
const roles = [
@ -17,13 +22,13 @@ export async function createServer(network, name, icon, allowAnyoneToInvite, isG
permissions: ['admin', 'send_messages', 'read_messages', 'manage_channels', 'manage_roles', 'kick_members', 'send_files', 'add_reactions', 'mention_everyone']
},
{
id: 'member',
name: 'Member',
id: 'role_members',
name: 'Members',
color: '#9ca3af',
permissions: ['send_messages', 'read_messages', 'send_files', 'add_reactions']
}
];
const memberRoles = { [network.myKey]: ['admin'] };
const memberRoles = { [network.myKey]: ['admin', 'role_members'] };
const serverInfo = { name, icon, owner: network.myKey, allowAnyoneToInvite, isGroupChat, channels, roles, memberRoles };
@ -47,7 +52,7 @@ export async function joinServer(network, topicHex, name, icon, owner, allowAnyo
owner,
allowAnyoneToInvite,
isGroupChat,
channels: channels || { text: ['general-chat'], voice: ['general-voice'], permissions: {}, send_permissions: {} },
channels: channels || { text: ['general-chat'], voice: ['general-voice'], permissions: { 'general-chat': ['role_members'], 'general-voice': ['role_members'] }, send_permissions: { 'general-chat': ['role_members'], 'general-voice': ['role_members'] } },
roles: roles || [],
memberRoles: memberRoles || {}
};