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()} + > + + + +
+
+ )} + {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 }) {
+ +
-
+
+ + {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} +
+
+
+ + + +
+
+ ))} + {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 }) { +
))} @@ -93,6 +161,11 @@ export default function FriendsView({ dms }) { @{data.profile?.username} • Outgoing Contact 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} +
+
+
+ +
+
+ ))} + {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.

-
+
+
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 (
@@ -107,8 +192,12 @@ export default function SetupScreen({ setProfile }) { Create New Account + +
)} @@ -174,49 +263,36 @@ export default function SetupScreen({ setProfile }) { )} {view === 'login' && ( -
-

Login with Seed

-

Paste your 64-character private key to restore your account.

+ +

Restore Account

+

Paste your 64-character private key to securely sync your account from another online device.

-
+
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..." />
-
- - 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} - /> -
-
- - 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} - /> -
-
- - {savedAccounts.length > 0 && } + + {savedAccounts.length > 0 && }
)} diff --git a/Peercord Source/src/p2p/handlers.js b/Peercord Source/src/p2p/handlers.js index 4ab36dd..a902ad3 100644 --- a/Peercord Source/src/p2p/handlers.js +++ b/Peercord Source/src/p2p/handlers.js @@ -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) { diff --git a/Peercord Source/src/p2p/index.js b/Peercord Source/src/p2p/index.js index 5540adc..cc80cc8 100644 --- a/Peercord Source/src/p2p/index.js +++ b/Peercord Source/src/p2p/index.js @@ -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'); - this.peers.set(peerKey, { conn, displayName: 'Unknown', username: 'unknown', avatar: null, bio: '', connections: [], coreKey: null }); + + // 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', () => { - this.peers.delete(peerKey); - if (this.onPeerUpdate) this.onPeerUpdate(this.getPeerList()); + // 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++; - } - - 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(); + 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 }); + 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(); + 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 }); + 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 (const server of this.servers) { + await this._joinTopic(server.topicHex, true); + await pace(); + } - for (let i = 0; i < this.localCore.length; i++) { this.processMessage(await this.localCore.get(i)); } + const globalUpdateTopic = b4a.alloc(32); + sodium.crypto_generichash(globalUpdateTopic, b4a.from('peercord-global-updates')); + this.swarm.join(globalUpdateTopic, { client: true, server: true }); - 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)); + this.swarm.flush().then(() => { + console.log("[P2P] Swarm flushed and announced."); + }).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 }); - this._broadcastIdentity(); + // 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() { diff --git a/Peercord Source/src/p2p/modules/discovery.js b/Peercord Source/src/p2p/modules/discovery.js index 546e9b5..83bfd4f 100644 --- a/Peercord Source/src/p2p/modules/discovery.js +++ b/Peercord Source/src/p2p/modules/discovery.js @@ -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)) { - const msg = await core.get(i); - network.processMessage(msg); - processedSeq = Math.max(processedSeq, 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(); } \ No newline at end of file diff --git a/Peercord Source/src/p2p/modules/messaging.js b/Peercord Source/src/p2p/modules/messaging.js index d2bcbc2..c6ef076 100644 --- a/Peercord Source/src/p2p/modules/messaging.js +++ b/Peercord Source/src/p2p/modules/messaging.js @@ -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 }); } diff --git a/Peercord Source/src/p2p/modules/servers.js b/Peercord Source/src/p2p/modules/servers.js index 575984e..cc5ea72 100644 --- a/Peercord Source/src/p2p/modules/servers.js +++ b/Peercord Source/src/p2p/modules/servers.js @@ -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 || {} };