v1.0.5
Lotta shit ngl
This commit is contained in:
parent
2689c5336c
commit
439791ae4d
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || {}
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user