+
+
+ {profile ? (
+ {
+ const seenKey = `seen_update_${version}`;
+ if (!sessionStorage.getItem(seenKey)) {
+ sessionStorage.setItem(seenKey, 'true');
+
+ console.info(`🚀 [P2P] Verified Admin Update Broadcast: v${version}`);
+
+ const autoRestart = localStorage.getItem('pear_auto_restart') !== 'false';
+ const reasons = network.getBusyReasons();
+ const isBusy = reasons.length > 0;
+
+ setUpdateState(prev => {
+ if (prev === 'downloading' || prev === 'available' || prev === 'countdown') return prev;
+
+ if (autoRestart && !isBusy) {
+ setCountdown(5);
+ return 'gossip_countdown';
+ } else {
+ setBusyReasons(reasons);
+ return 'gossip_available';
+ }
+ });
+
+ setFlyoutDismissed(false);
+
+ // Gossip to all connected peers to ensure network-wide delivery instantly
+ network.sendEphemeral(payload);
+ }
+ }}
+ />
+ ) : (
+
+ )}
+
+ {showConsole &&
setShowConsole(false)} />}
+
+ {/* Update Notification Flyout */}
+ {updateState && !flyoutDismissed && (
+
+
+
+
+
+
+ {updateState === 'downloading' ? 'Downloading Update...' :
+ (updateState === 'gossip_available' || updateState === 'gossip_countdown') ? 'Update Broadcasted' : 'Update Available'}
+
+
+ {updateState === 'downloading' ? 'A new version is being downloaded.' :
+ (updateState === 'gossip_available' || updateState === 'gossip_countdown') ? 'A new version has been announced on the network.' : 'A new version of Peercord is ready.'}
+
+
+
+
+
+
+ {updateState === 'downloading' ? (
+
+
+
+ Downloading update...
+
+
+ ) : updateState === 'countdown' ? (
+
+
Restarting in {countdown}s...
+
+
+
+
+
+ ) : updateState === 'gossip_available' || updateState === 'gossip_countdown' ? (
+
+ {busyReasons.length > 0 && (localStorage.getItem('pear_auto_restart') !== 'false') && (
+
+
Auto-Restart Paused:
+
+ {busyReasons.map((r, i) => - {r}
)}
+
+
+ )}
+
New Update Broadcasted!
+
+ {updateState === 'gossip_countdown'
+ ? `Restarting in ${countdown}s to connect to the new seeder...`
+ : 'Restart the app to connect to the new seeder and begin downloading.'}
+
+
+ {updateState === 'gossip_countdown' ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ ) : (
+
+ {busyReasons.length > 0 && (localStorage.getItem('pear_auto_restart') !== 'false') && (
+
+
Auto-Restart Paused:
+
+ {busyReasons.map((r, i) => - {r}
)}
+
+
+ )}
+
+
+
+
+
+ )}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/CallView.jsx b/Peercord Source/src/components/CallView.jsx
new file mode 100644
index 0000000..3a7ade3
--- /dev/null
+++ b/Peercord Source/src/components/CallView.jsx
@@ -0,0 +1,639 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { network } from '../p2p/index.js';
+import ScreenShareModal from './ScreenShareModal.jsx';
+
+export default function CallView({ targetKey, targetProfile, myProfile, isCaller, status, onClose, onToggleChat, onConnected, className, initialVideoOn }) {
+ const [isMuted, setIsMuted] = useState(false);
+ const [isVideoOn, setIsVideoOn] = useState(initialVideoOn || false);
+ const [localVoiceActive, setLocalVoiceActive] = useState(false);
+ const [remoteVoiceActive, setRemoteVoiceActive] = useState(false);
+
+ const [showScreenShareModal, setShowScreenShareModal] = useState(false);
+ const [isScreenSharing, setIsScreenSharing] = useState(false);
+ const [hasRemoteVideo, setHasRemoteVideo] = useState(false);
+ const [remoteVideoStream, setRemoteVideoStream] = useState(null);
+ const [isFullscreen, setIsFullscreen] = useState(false);
+
+ const pcRef = useRef(null);
+ const localStreamRef = useRef(null);
+ const localScreenStreamRef = useRef(null);
+ const localCameraStreamRef = useRef(null);
+
+ const remoteAudioRef = useRef(null);
+ const remoteVideoRef = useRef(null);
+ const localVideoRef = useRef(null);
+
+ const animationFrameRef = useRef(null);
+ const audioCtxRef = useRef(null);
+
+ const pendingCandidates = useRef([]);
+ const pendingSignals = useRef([]);
+ const isProcessingSignals = useRef(false);
+ const drainPendingSignalsRef = useRef(null);
+ const [mediaReady, setMediaReady] = useState(false);
+
+ const isLocalVideoActive = isScreenSharing || isVideoOn;
+ const isVideoActive = hasRemoteVideo || isLocalVideoActive;
+
+ useEffect(() => {
+ if (localVideoRef.current) {
+ if (isScreenSharing && localScreenStreamRef.current) {
+ localVideoRef.current.srcObject = localScreenStreamRef.current;
+ } else if (isVideoOn && localCameraStreamRef.current) {
+ localVideoRef.current.srcObject = localCameraStreamRef.current;
+ } else {
+ localVideoRef.current.srcObject = null;
+ }
+ }
+ }, [isScreenSharing, isVideoOn, hasRemoteVideo, isFullscreen]);
+
+ useEffect(() => {
+ if (remoteVideoRef.current) {
+ if (hasRemoteVideo && remoteVideoStream) {
+ remoteVideoRef.current.srcObject = remoteVideoStream;
+ } else {
+ remoteVideoRef.current.srcObject = null;
+ }
+ }
+ }, [hasRemoteVideo, isFullscreen, remoteVideoStream]);
+
+ const initPC = async () => {
+ const pc = new RTCPeerConnection({ iceServers:[{ urls: 'stun:stun.l.google.com:19302' }] });
+ pcRef.current = pc;
+
+ if (localStreamRef.current) {
+ localStreamRef.current.getTracks().forEach(track => pc.addTrack(track, localStreamRef.current));
+ }
+ if (localCameraStreamRef.current) {
+ localCameraStreamRef.current.getTracks().forEach(track => pc.addTrack(track, localCameraStreamRef.current));
+ }
+
+ pc.onconnectionstatechange = () => {
+ if (pc.connectionState === 'connected') {
+ onConnected();
+ }
+ };
+
+ pc.oniceconnectionstatechange = () => {
+ if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
+ onConnected();
+ }
+ };
+
+ pc.onicecandidate = (e) => {
+ if (e.candidate) {
+ network.sendWebRTCSignal(targetKey, { type: 'webrtc-ice-candidate', candidate: e.candidate });
+ }
+ };
+
+ pc.ontrack = (e) => {
+ if (e.track.kind === 'video') {
+ setRemoteVideoStream(e.streams[0]);
+ setHasRemoteVideo(true);
+
+ e.track.onended = () => {
+ setHasRemoteVideo(false);
+ setRemoteVideoStream(null);
+ setIsFullscreen(false);
+ };
+ e.track.onmute = () => {
+ setHasRemoteVideo(false);
+ setIsFullscreen(false);
+ };
+ e.track.onunmute = () => {
+ setHasRemoteVideo(true);
+ };
+ } else if (e.track.kind === 'audio') {
+ if (remoteAudioRef.current) {
+ remoteAudioRef.current.srcObject = e.streams[0];
+ const outputId = localStorage.getItem('pear_audio_output');
+ if (outputId && outputId !== 'default' && remoteAudioRef.current.setSinkId) {
+ remoteAudioRef.current.setSinkId(outputId).catch(console.error);
+ }
+ }
+ }
+ };
+
+ if (drainPendingSignalsRef.current) {
+ await drainPendingSignalsRef.current();
+ }
+
+ return pc;
+ };
+
+ useEffect(() => {
+ const setupMedia = async () => {
+ try {
+ const audioInputId = localStorage.getItem('pear_audio_input');
+ const aStream = await navigator.mediaDevices.getUserMedia({
+ audio: audioInputId && audioInputId !== 'default' ? { deviceId: { exact: audioInputId } } : true
+ });
+ localStreamRef.current = aStream;
+
+ if (initialVideoOn) {
+ try {
+ const videoInputId = localStorage.getItem('pear_video_input');
+ const vStream = await navigator.mediaDevices.getUserMedia({
+ video: videoInputId && videoInputId !== 'default' ? { deviceId: { exact: videoInputId } } : true
+ });
+ localCameraStreamRef.current = vStream;
+ } catch (err) {
+ console.error("Failed to get video", err);
+ setIsVideoOn(false);
+ }
+ }
+
+ setMediaReady(true);
+
+ const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+ audioCtxRef.current = audioCtx;
+ const analyser = audioCtx.createAnalyser();
+ const source = audioCtx.createMediaStreamSource(aStream);
+ source.connect(analyser);
+ analyser.fftSize = 256;
+ const bufferLength = analyser.frequencyBinCount;
+ const dataArray = new Uint8Array(bufferLength);
+
+ let lastSpeakingState = false;
+ const checkAudio = () => {
+ analyser.getByteFrequencyData(dataArray);
+ let sum = 0;
+ for (let i = 0; i < bufferLength; i++) sum += dataArray[i];
+ const average = sum / bufferLength;
+ const isSpeaking = average > 15;
+
+ if (isSpeaking !== lastSpeakingState) {
+ network.sendWebRTCSignal(targetKey, { type: 'voice_activity', state: isSpeaking ? 'speaking' : 'silent' });
+ setLocalVoiceActive(isSpeaking);
+ lastSpeakingState = isSpeaking;
+ }
+ animationFrameRef.current = requestAnimationFrame(checkAudio);
+ };
+ checkAudio();
+
+ if (!isCaller) {
+ initPC();
+ }
+
+ } catch (err) {
+ console.error("Failed to access microphone:", err);
+ alert("Could not access microphone. Please check your permissions.");
+ onClose();
+ }
+ };
+
+ setupMedia();
+
+ return () => {
+ if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
+ if (audioCtxRef.current) audioCtxRef.current.close().catch(() => {});
+ if (localStreamRef.current) localStreamRef.current.getTracks().forEach(t => t.stop());
+ if (localScreenStreamRef.current) localScreenStreamRef.current.getTracks().forEach(t => t.stop());
+ if (localCameraStreamRef.current) localCameraStreamRef.current.getTracks().forEach(t => t.stop());
+ if (pcRef.current) pcRef.current.close();
+ network.sendWebRTCSignal(targetKey, { type: 'webrtc-end' });
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[]);
+
+ useEffect(() => {
+ if (isCaller && status === 'connecting' && !pcRef.current && mediaReady) {
+ initPC().then(async (pc) => {
+ const offer = await pc.createOffer();
+ await pc.setLocalDescription(offer);
+ network.sendWebRTCSignal(targetKey, { type: 'webrtc-offer', sdp: offer });
+ });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[status, isCaller, targetKey, mediaReady]);
+
+ useEffect(() => {
+ const processSignal = async (payload) => {
+ const pc = pcRef.current;
+ if (!pc) return;
+
+ if (payload.type === 'webrtc-offer') {
+ await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp));
+ for (const candidate of pendingCandidates.current) {
+ await pc.addIceCandidate(candidate).catch(console.error);
+ }
+ pendingCandidates.current =[];
+
+ const answer = await pc.createAnswer();
+ await pc.setLocalDescription(answer);
+ network.sendWebRTCSignal(targetKey, { type: 'webrtc-answer', sdp: answer });
+
+ } else if (payload.type === 'webrtc-answer') {
+ await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp));
+ for (const candidate of pendingCandidates.current) {
+ await pc.addIceCandidate(candidate).catch(console.error);
+ }
+ pendingCandidates.current =[];
+ onConnected();
+
+ } else if (payload.type === 'webrtc-ice-candidate') {
+ const candidate = new RTCIceCandidate(payload.candidate);
+ if (pc.remoteDescription && pc.remoteDescription.type) {
+ await pc.addIceCandidate(candidate).catch(console.error);
+ } else {
+ pendingCandidates.current.push(candidate);
+ }
+
+ } else if (payload.type === 'voice_activity') {
+ setRemoteVoiceActive(payload.state === 'speaking');
+ }
+ };
+
+ const drainPendingSignals = async () => {
+ if (isProcessingSignals.current) return;
+ isProcessingSignals.current = true;
+ while (pendingSignals.current.length > 0) {
+ const payload = pendingSignals.current.shift();
+ await processSignal(payload);
+ }
+ isProcessingSignals.current = false;
+ };
+
+ drainPendingSignalsRef.current = drainPendingSignals;
+
+ const handleSignal = async (peerKey, payload) => {
+ if (peerKey !== targetKey) return;
+
+ try {
+ if (!pcRef.current && payload.type !== 'voice_activity') {
+ pendingSignals.current.push(payload);
+ return;
+ }
+
+ if (pcRef.current) {
+ pendingSignals.current.push(payload);
+ await drainPendingSignals();
+ } else if (payload.type === 'voice_activity') {
+ setRemoteVoiceActive(payload.state === 'speaking');
+ }
+ } catch (err) {
+ console.error("Error handling WebRTC signal:", err);
+ }
+ };
+
+ network.addWebRTCListener(handleSignal);
+ return () => network.removeWebRTCListener(handleSignal);
+ }, [targetKey, onConnected]);
+
+ const toggleMute = () => {
+ if (localStreamRef.current) {
+ const audioTrack = localStreamRef.current.getAudioTracks()[0];
+ if (audioTrack) {
+ audioTrack.enabled = !audioTrack.enabled;
+ setIsMuted(!audioTrack.enabled);
+ }
+ }
+ };
+
+ const toggleVideo = async () => {
+ if (isVideoOn) {
+ if (localCameraStreamRef.current) {
+ const track = localCameraStreamRef.current.getVideoTracks()[0];
+ const sender = pcRef.current?.getSenders().find(s => s.track === track);
+ if (sender && pcRef.current) pcRef.current.removeTrack(sender);
+ track.stop();
+ localCameraStreamRef.current = null;
+ }
+ setIsVideoOn(false);
+
+ if (pcRef.current && pcRef.current.signalingState !== 'closed') {
+ const offer = await pcRef.current.createOffer();
+ await pcRef.current.setLocalDescription(offer);
+ network.sendWebRTCSignal(targetKey, { type: 'webrtc-offer', sdp: offer });
+ }
+ } else {
+ try {
+ const videoInputId = localStorage.getItem('pear_video_input');
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: videoInputId && videoInputId !== 'default' ? { deviceId: { exact: videoInputId } } : true
+ });
+ localCameraStreamRef.current = stream;
+ const track = stream.getVideoTracks()[0];
+ if (pcRef.current) {
+ pcRef.current.addTrack(track, stream);
+ const offer = await pcRef.current.createOffer();
+ await pcRef.current.setLocalDescription(offer);
+ network.sendWebRTCSignal(targetKey, { type: 'webrtc-offer', sdp: offer });
+ }
+ setIsVideoOn(true);
+ } catch (err) {
+ console.error("Failed to start video", err);
+ }
+ }
+ };
+
+ const startScreenShare = async (sourceId, res, fps) => {
+ setShowScreenShareModal(false);
+ let stream = null;
+
+ try {
+ if (sourceId === 'native') {
+ stream = await navigator.mediaDevices.getDisplayMedia({
+ video: true,
+ audio: false
+ });
+ } else {
+ try {
+ stream = await navigator.mediaDevices.getUserMedia({
+ audio: false,
+ video: {
+ mandatory: {
+ chromeMediaSource: 'desktop',
+ chromeMediaSourceId: sourceId,
+ maxWidth: res.width,
+ maxHeight: res.height,
+ maxFrameRate: fps
+ }
+ }
+ });
+ } catch (initialErr) {
+ console.warn("Optimal capture rejected. Using fallback.", initialErr);
+ stream = await navigator.mediaDevices.getUserMedia({
+ audio: false,
+ video: {
+ mandatory: {
+ chromeMediaSource: 'desktop',
+ chromeMediaSourceId: sourceId
+ }
+ }
+ });
+ }
+ }
+
+ const videoTrack = stream.getVideoTracks()[0];
+ videoTrack.contentHint = 'motion';
+
+ videoTrack.onended = () => {
+ stopScreenShare();
+ };
+
+ localScreenStreamRef.current = stream;
+ setIsScreenSharing(true);
+
+ const sender = pcRef.current.addTrack(videoTrack, stream);
+
+ try {
+ const params = sender.getParameters();
+ if (!params.encodings || params.encodings.length === 0) {
+ params.encodings = [{}];
+ }
+
+ params.encodings[0].maxFramerate = fps;
+
+ let maxBitrate = 8000000;
+ if (res.height <= 720) maxBitrate = 4000000;
+ if (res.height <= 480) maxBitrate = 1500000;
+ if (res.height <= 360) maxBitrate = 800000;
+
+ params.encodings[0].maxBitrate = maxBitrate;
+
+ if ('degradationPreference' in params) {
+ params.degradationPreference = 'maintain-framerate';
+ }
+
+ await sender.setParameters(params);
+ } catch (paramErr) {
+ console.warn("Could not set sender parameters:", paramErr);
+ }
+
+ const offer = await pcRef.current.createOffer();
+ await pcRef.current.setLocalDescription(offer);
+ network.sendWebRTCSignal(targetKey, { type: 'webrtc-offer', sdp: offer });
+
+ } catch (err) {
+ console.error("Screen share failed or cancelled", err);
+
+ if (localVideoRef.current) localVideoRef.current.srcObject = null;
+ if (stream) stream.getTracks().forEach(t => { t.enabled = false; t.stop(); });
+ if (localScreenStreamRef.current) {
+ localScreenStreamRef.current.getTracks().forEach(t => { t.enabled = false; t.stop(); });
+ localScreenStreamRef.current = null;
+ }
+
+ setIsScreenSharing(false);
+
+ if (err.name !== 'NotAllowedError' && err.name !== 'AbortError') {
+ alert(`Could not capture this window/screen.\nError: ${err.name} - ${err.message}`);
+ }
+ }
+ };
+
+ const stopScreenShare = async () => {
+ if (localVideoRef.current) {
+ localVideoRef.current.srcObject = null;
+ }
+
+ let trackToRemove = null;
+ if (localScreenStreamRef.current) {
+ trackToRemove = localScreenStreamRef.current.getVideoTracks()[0];
+ localScreenStreamRef.current.getTracks().forEach(t => {
+ t.enabled = false;
+ t.stop();
+ });
+ localScreenStreamRef.current = null;
+ }
+
+ setIsScreenSharing(false);
+ setIsFullscreen(false);
+
+ if (pcRef.current && trackToRemove) {
+ const senders = pcRef.current.getSenders();
+ const videoSender = senders.find(s => s.track === trackToRemove);
+ if (videoSender) {
+ try {
+ pcRef.current.removeTrack(videoSender);
+ if (pcRef.current.signalingState !== 'closed') {
+ const offer = await pcRef.current.createOffer();
+ await pcRef.current.setLocalDescription(offer);
+ network.sendWebRTCSignal(targetKey, { type: 'webrtc-offer', sdp: offer });
+ }
+ } catch (e) {
+ console.warn("Failed to negotiate track removal", e);
+ }
+ }
+ }
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+ Call: {targetProfile.displayName}
+
+ {status === 'ringing' ? (
+ <>
+ Ringing
+
+
+
+
+
+ >
+ ) : status === 'connecting' ? (
+ 'Connecting...'
+ ) : (
+ Connected
+ )}
+
+
+
+ {/* Main Call Area */}
+
+
+ {/* Video Area */}
+ {isVideoActive && (
+
!isFullscreen && setIsFullscreen(true)}
+ >
+ {hasRemoteVideo && (
+
+ )}
+ {isLocalVideoActive && !hasRemoteVideo && (
+
+ )}
+
+ {/* Small PiP if both are sharing */}
+ {hasRemoteVideo && isLocalVideoActive && (
+
+
+
+ )}
+
+ {!isFullscreen && (
+
+
+
+ Click to Enlarge
+
+
+ )}
+
+ {isFullscreen && (
+
+ )}
+
+ )}
+
+ {/* User Squares Grid */}
+
+
+ {/* Remote User Square */}
+
+ {status === 'ringing' && (
+
+ )}
+
+ {targetProfile.avatar ? (
+

+ ) : (
+ targetProfile.displayName?.substring(0, 2).toUpperCase() || '?'
+ )}
+
+
{targetProfile.displayName || 'Unknown'}
+
+
+ {/* Local User Square */}
+
+
+ {myProfile.avatar ? (
+

+ ) : (
+ myProfile.displayName?.substring(0, 2).toUpperCase() || '?'
+ )}
+
+
{myProfile.displayName} (You)
+
+
+
+
+
+
+ {/* Bottom Controls */}
+
+
+
+
+
+
+
+ {isScreenSharing ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {showScreenShareModal && (
+
setShowScreenShareModal(false)}
+ onStart={startScreenShare}
+ />
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/ChannelList.jsx b/Peercord Source/src/components/ChannelList.jsx
new file mode 100644
index 0000000..60943bf
--- /dev/null
+++ b/Peercord Source/src/components/ChannelList.jsx
@@ -0,0 +1,212 @@
+import React, { useState } from 'react';
+import { network, ADMIN_PUBLIC_KEY } from '../p2p/index.js';
+import CreateChannelModal from './CreateChannelModal.jsx';
+
+export default function ChannelList({ activeChannel, setActiveChannel, myKey, profile, unreadCounts, onOpenSettings, activeView, servers, onOpenInvite, onOpenServerSettings, isSyncing, onlinePeers, knownUsers, serverMembers, activeCall, onReturnToCall, vcStates, activeVc, onJoinVC, isNetworkOnline }) {
+ const activeServerObj = servers.find(s => s.topicHex === activeView);
+ const serverName = activeServerObj ? activeServerObj.name : 'Unknown Hub';
+
+ const isAdmin = activeServerObj?.owner === myKey;
+ const canInvite = isAdmin || activeServerObj?.allowAnyoneToInvite;
+
+ const currentMembers = new Set(serverMembers[activeView] ||[]);
+ if (activeServerObj) currentMembers.add(activeServerObj.owner);
+
+ const onlineServerPeers = onlinePeers.filter(p => p.key !== myKey && currentMembers.has(p.key));
+ const hasOnlinePeers = onlineServerPeers.length > 0;
+
+ const [isCreateChannelOpen, setIsCreateChannelOpen] = useState(false);
+ const [createChannelType, setCreateChannelType] = useState('text');
+
+ let syncText = 'Synced';
+ let syncColor = 'bg-green-500';
+ if (!hasOnlinePeers) {
+ syncText = 'Waiting for Peers';
+ syncColor = 'bg-gray-500';
+ } else if (isSyncing) {
+ syncText = 'Syncing...';
+ syncColor = 'bg-yellow-500 animate-pulse';
+ }
+
+ const textChannels = activeServerObj?.channels?.text || ['general-chat'];
+ const voiceChannels = activeServerObj?.channels?.voice || ['general-voice'];
+
+ const handleCreateChannel = (name, type) => {
+ const newChannels = {
+ text: [...textChannels],
+ voice: [...voiceChannels]
+ };
+
+ if (type === 'text' && !newChannels.text.includes(name)) newChannels.text.push(name);
+ if (type === 'voice' && !newChannels.voice.includes(name)) newChannels.voice.push(name);
+
+ network.updateServerSettings(activeView, activeServerObj.name, activeServerObj.icon, activeServerObj.allowAnyoneToInvite, newChannels);
+ setIsCreateChannelOpen(false);
+ };
+
+ const renderChannel = (id, name) => {
+ const isActive = activeChannel === id;
+ const networkId = `${activeView}-${id}`;
+ const unread = unreadCounts[networkId] || 0;
+ const hasUnread = unread > 0 && !isActive;
+
+ return (
+ setActiveChannel(id)}
+ 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'
+ }`}
+ >
+
+ #
+ {name}
+
+ {hasUnread && (
+
+ {unread > 99 ? '99+' : unread}
+
+ )}
+
+ );
+ };
+
+ const renderVoiceChannel = (id, name) => {
+ const isActive = activeVc?.channelId === id && activeVc?.serverId === activeView;
+ const vcPeers = vcStates[activeView]?.[id] || {};
+ const peerKeys = Object.keys(vcPeers);
+
+ return (
+
+
onJoinVC(id)}
+ className={`px-2 py-1.5 rounded cursor-pointer flex items-center gap-2 group ${
+ isActive ? 'bg-panel text-text' : 'text-muted hover:bg-panel/50 hover:text-text'
+ }`}
+ >
+ 🔊
+ {name}
+
+
+ {peerKeys.length > 0 && (
+
+ {peerKeys.map(peerKey => {
+ const state = vcPeers[peerKey];
+ let peerProfile = knownUsers.find(u => u.key === peerKey);
+ if (peerKey === myKey) peerProfile = profile;
+ if (!peerProfile) return null;
+
+ return (
+
+
+
+ {peerProfile.avatar ?

: peerProfile.displayName.substring(0, 2).toUpperCase()}
+
+
{peerProfile.displayName}
+
+
+ {state.screenshare && (
+
+ )}
+ {state.muted && (
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ );
+ };
+
+ return (
+
+
+ {activeCall && (
+
+ )}
+
+
+
+
+
+
+ {canInvite && (
+
+ )}
+ {isAdmin && (
+
+ )}
+
+
+
+ Text Rooms
+ {isAdmin && }
+
+ {textChannels.map(ch => renderChannel(ch, ch))}
+
+
+ Voice Rooms
+ {isAdmin && }
+
+ {voiceChannels.map(ch => renderVoiceChannel(ch, ch))}
+
+
+
+
+
+ {profile.avatar ? (
+

+ ) : (
+ profile.displayName.substring(0, 2).toUpperCase()
+ )}
+
+
+
+
+
+ {profile.displayName}
+ {myKey === ADMIN_PUBLIC_KEY && 👑}
+
+ @{profile.username}
+
+
+
+ {isCreateChannelOpen && (
+
setIsCreateChannelOpen(false)}
+ onSave={handleCreateChannel}
+ defaultType={createChannelType}
+ />
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/ChatArea.jsx b/Peercord Source/src/components/ChatArea.jsx
new file mode 100644
index 0000000..00d92e3
--- /dev/null
+++ b/Peercord Source/src/components/ChatArea.jsx
@@ -0,0 +1,815 @@
+import React, { useState, useRef, useEffect } from 'react';
+import { network, ADMIN_PUBLIC_KEY } from '../p2p/index.js';
+import ServerInviteCard from './ServerInviteCard.jsx';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import remarkBreaks from 'remark-breaks';
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
+import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
+
+const MarkdownComponents = {
+ code({node, inline, className, children, ...props}) {
+ const match = /language-(\w+)/.exec(className || '')
+ return !inline && match ? (
+ {
+ // Start the character-by-character decryption wave
+ interval = setInterval(() => {
+ curr += DECRYPTION_CHARS_PER_TICK;
+ if (curr >= text.length) {
+ setRevealed(text.length);
+ setIsAnimating(false);
+ clearInterval(interval);
+ } else {
+ setRevealed(curr);
+ }
+ }, DECRYPTION_SPEED_MS);
+ }, DECRYPTION_INITIAL_DELAY_MS);
+
+ return () => {
+ clearTimeout(timeout);
+ if (interval) clearInterval(interval);
+ };
+ }, [msg.text, msg.cipher, msg.isEncrypted, liveDecryption, msg.id, animationTrigger]);
+
+ if (!isAnimating) {
+ return (
+
+
+ {msg.text || ''}
+
+
+ );
+ }
+
+ return (
+
+ {msg.text.substring(0, revealed)}
+ {gibberish.substring(revealed)}
+
+ );
+};
+
+export default function ChatArea({ activeView, activeChannel, messages, myKey, profile, typingUsers, readReceipts, deliveredReceipts, onlinePeers, markChannelRead, dms, servers, onStartCall, activeCall, onReturnToCall, transfers, onOpenInvite, onToggleMembers }) {
+ const[inputText, setInputText] = useState('');
+ const[editingId, setEditingId] = useState(null);
+ const[editInput, setEditInput] = useState('');
+ const[activeTypers, setActiveTypers] = useState([]);
+
+ const [attachments, setAttachments] = useState([]);
+ const[isDragging, setIsDragging] = useState(false);
+ const[expandedImage, setExpandedImage] = useState(null);
+ const [contextMenu, setContextMenu] = useState(null);
+
+ // Visual Decryption State
+ const [showCrypto, setShowCrypto] = useState(false);
+ const [liveDecryption, setLiveDecryption] = useState(localStorage.getItem('pear_live_decryption') === 'true');
+ const [animationTrigger, setAnimationTrigger] = useState(0);
+
+ const messagesEndRef = useRef(null);
+ const chatContainerRef = useRef(null);
+ const textareaRef = useRef(null);
+ const editTextareaRef = useRef(null);
+ const fileInputRef = useRef(null);
+ const lastTypingTime = useRef(0);
+
+ const isDMView = activeView === 'dms';
+ const gcObj = isDMView ? servers.find(s => s.topicHex === activeChannel && s.isGroupChat) : null;
+ const isGroupChat = !!gcObj;
+
+ const networkChannelId = isGroupChat ? activeChannel : (isDMView ? activeChannel : `${activeView}-${activeChannel}`);
+
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "auto" });
+ },[activeChannel, activeView]);
+
+ useEffect(() => {
+ const handleClick = () => setContextMenu(null);
+ if (contextMenu) document.addEventListener('click', handleClick);
+ return () => document.removeEventListener('click', handleClick);
+ }, [contextMenu]);
+
+ useEffect(() => {
+ if (chatContainerRef.current) {
+ const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current;
+ const isNearBottom = scrollHeight - scrollTop - clientHeight < 200;
+ if (isNearBottom) {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ }
+ }
+
+ const sendRead = () => {
+ const currentChannelMessages = messages.filter(m => {
+ if (isDMView && !isGroupChat) return (m.sender === myKey && m.recipient === activeChannel) || (m.sender === activeChannel && m.recipient === myKey);
+ return m.channel === networkChannelId && !m.recipient;
+ });
+
+ const latestMsg = currentChannelMessages[currentChannelMessages.length - 1];
+ const latestMsgId = latestMsg ? latestMsg.id : null;
+
+ if (latestMsgId) network.sendReadReceipt(networkChannelId, latestMsgId);
+ markChannelRead(networkChannelId);
+ };
+
+ sendRead();
+ const interval = setInterval(sendRead, 3000);
+ return () => clearInterval(interval);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[messages.length, activeChannel, isDMView, isGroupChat, myKey, onlinePeers.length, activeView]);
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ const now = Date.now();
+ const typers = Object.entries(typingUsers)
+ .filter(([key, info]) => {
+ if (now - info.timestamp >= 3000 || key === myKey) return false;
+ return (isDMView && !isGroupChat) ? (key === activeChannel && info.channel === myKey) : (info.channel === networkChannelId);
+ })
+ .map(([_, info]) => info.displayName);
+ setActiveTypers(typers);
+ }, 1000);
+ return () => clearInterval(interval);
+ },[typingUsers, activeChannel, myKey, isDMView, isGroupChat, networkChannelId]);
+
+ useEffect(() => {
+ if (editingId && editTextareaRef.current) {
+ editTextareaRef.current.style.height = 'auto';
+ editTextareaRef.current.style.height = `${Math.min(editTextareaRef.current.scrollHeight, 400)}px`;
+ editTextareaRef.current.focus();
+ }
+ },[editingId]);
+
+ const processFiles = async (files) => {
+ const newAttachments =[];
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ newAttachments.push({
+ name: file.name,
+ size: file.size,
+ type: file.type,
+ path: file.path,
+ fileObj: file,
+ preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : null
+ });
+ }
+ setAttachments(prev =>[...prev, ...newAttachments]);
+ };
+
+ const handleDrop = (e) => {
+ e.preventDefault();
+ setIsDragging(false);
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
+ processFiles(e.dataTransfer.files);
+ }
+ };
+
+ const handlePaste = (e) => {
+ if (e.clipboardData.files && e.clipboardData.files.length > 0) {
+ processFiles(e.clipboardData.files);
+ }
+ };
+
+ const handleInputChange = (e) => {
+ setInputText(e.target.value);
+
+ if (textareaRef.current) {
+ textareaRef.current.style.height = 'auto';
+ textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 400)}px`;
+ }
+
+ const now = Date.now();
+ if (now - lastTypingTime.current > 2000) {
+ network.sendTyping(networkChannelId);
+ lastTypingTime.current = now;
+ }
+ };
+
+ const handleSendMessage = (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+
+ if (attachments.length > 0) {
+ for (let i = 0; i < attachments.length; i++) {
+ const textToSend = i === 0 ? inputText.trim() : '';
+ if (isDMView && !isGroupChat) network.sendDMFile(activeChannel, textToSend, attachments[i]);
+ else network.sendFile(networkChannelId, textToSend, attachments[i]);
+ }
+ setAttachments([]);
+ setInputText('');
+ if (textareaRef.current) textareaRef.current.style.height = 'auto';
+ } else if (inputText.trim() !== '') {
+ if (isDMView && !isGroupChat) network.sendDM(activeChannel, inputText.trim());
+ else network.sendMessage(networkChannelId, inputText.trim());
+ setInputText('');
+ if (textareaRef.current) textareaRef.current.style.height = 'auto';
+ }
+ }
+ };
+
+ const startEditing = (msg) => {
+ setEditingId(msg.id);
+ setEditInput(msg.text);
+ };
+
+ const handleEditChange = (e) => {
+ setEditInput(e.target.value);
+ if (editTextareaRef.current) {
+ editTextareaRef.current.style.height = 'auto';
+ editTextareaRef.current.style.height = `${Math.min(editTextareaRef.current.scrollHeight, 400)}px`;
+ }
+ };
+
+ const handleEditMessage = (e, id) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ if (editInput.trim() !== '') network.sendEditMessage(id, editInput.trim());
+ setEditingId(null);
+ } else if (e.key === 'Escape') {
+ setEditingId(null);
+ }
+ };
+
+ const handleOpenFolder = async (filePath) => {
+ if (!filePath) return;
+ try {
+ if (typeof Pear !== 'undefined') {
+ const { spawn } = await import('child_process');
+ const os = await import('os');
+ const path = await import('path');
+ const platform = os.platform();
+
+ if (platform === 'win32') {
+ const child = spawn('explorer.exe',['/select,', filePath], { detached: true });
+ child.unref();
+ } else if (platform === 'darwin') {
+ const child = spawn('open',['-R', filePath], { detached: true });
+ child.unref();
+ } else {
+ const dir = path.dirname(filePath);
+ const child = spawn('xdg-open',[dir], { detached: true });
+ child.unref();
+ }
+ } else if (typeof window !== 'undefined' && window.require) {
+ const { shell } = window.require('electron');
+ shell.showItemInFolder(filePath);
+ }
+ } catch (err) {
+ console.error("Failed to open folder:", err.message || err);
+ }
+ };
+
+ const handleToggleLiveDecryption = () => {
+ const newVal = !liveDecryption;
+ setLiveDecryption(newVal);
+ localStorage.setItem('pear_live_decryption', newVal);
+ if (newVal) {
+ setAnimationTrigger(Date.now()); // Force re-animation wave
+ }
+ };
+
+ const currentChannelMessages = messages.filter(m => {
+ if (isDMView && !isGroupChat) return (m.sender === myKey && m.recipient === activeChannel) || (m.sender === activeChannel && m.recipient === myKey);
+ return m.channel === networkChannelId && !m.recipient;
+ });
+
+ const myMessages = currentChannelMessages.filter(m => m.sender === myKey);
+ const lastMyMessageId = myMessages.length > 0 ? myMessages[myMessages.length - 1].id : null;
+
+ const readMsgId = readReceipts[networkChannelId];
+ const explicitDeliveredMsgId = deliveredReceipts ? deliveredReceipts[networkChannelId] : null;
+
+ const readMsgIndex = currentChannelMessages.findIndex(m => m.id === readMsgId);
+ const explicitDeliveredMsgIndex = currentChannelMessages.findIndex(m => m.id === explicitDeliveredMsgId);
+
+ const isPeerOnline = (isDMView && !isGroupChat) ? onlinePeers.some(p => p.key === activeChannel) : onlinePeers.length > 0;
+
+ let effectiveDeliveredMsgIndex = explicitDeliveredMsgIndex;
+ if (isPeerOnline && isDMView && !isGroupChat) effectiveDeliveredMsgIndex = currentChannelMessages.length - 1;
+
+ let lastMyReadMsgId = null;
+ let lastMyDeliveredMsgId = null;
+
+ for (let i = currentChannelMessages.length - 1; i >= 0; i--) {
+ const m = currentChannelMessages[i];
+ if (m.sender === myKey) {
+ if (!lastMyDeliveredMsgId && effectiveDeliveredMsgIndex !== -1 && i <= effectiveDeliveredMsgIndex) lastMyDeliveredMsgId = m.id;
+ if (!lastMyReadMsgId && readMsgIndex !== -1 && i <= readMsgIndex) lastMyReadMsgId = m.id;
+ }
+ }
+
+ const getMessageStatus = (msg) => {
+ if (msg.sender !== myKey) return null;
+ const msgIndex = currentChannelMessages.findIndex(m => m.id === msg.id);
+
+ if (isDMView && !isGroupChat) {
+ if (msg.id === lastMyReadMsgId) {
+ const hasNewerReply = currentChannelMessages.slice(msgIndex + 1).some(m => m.sender !== myKey);
+ if (!hasNewerReply) {
+ const targetProfile = dms[activeChannel]?.profile || {};
+ return (
+
+ {targetProfile.avatar ? (
+

+ ) : (
+
+ {targetProfile.displayName?.substring(0, 2).toUpperCase() || '?'}
+
+ )}
+
+ );
+ }
+ }
+
+ const isAfterRead = readMsgIndex === -1 || msgIndex > readMsgIndex;
+ if (isAfterRead) {
+ const isDelivered = effectiveDeliveredMsgIndex !== -1 && msgIndex <= effectiveDeliveredMsgIndex;
+ if (isDelivered) return ✓✓;
+ else return ✓;
+ }
+ return null;
+ } else {
+ if (msg.id !== lastMyMessageId) return null;
+ const isRead = readMsgIndex !== -1 && msgIndex <= readMsgIndex;
+ if (isRead) return ✓✓;
+ if (isPeerOnline) return ✓✓;
+ return ✓;
+ }
+ };
+
+ let isAdmin = myKey === ADMIN_PUBLIC_KEY;
+ if (!isDMView || isGroupChat) {
+ const activeServerObj = servers.find(s => s.topicHex === (isGroupChat ? activeChannel : activeView));
+ if (activeServerObj && activeServerObj.owner === myKey) isAdmin = true;
+ }
+
+ const canPost = true;
+ const headerName = isGroupChat ? gcObj.name : (isDMView ? (dms[activeChannel]?.profile?.displayName || 'Unknown') : activeChannel);
+ const headerIcon = isGroupChat ? '👥' : (isDMView ? '@' : '#');
+
+ let typingText = '';
+ if (activeTypers.length === 1) typingText = `${activeTypers[0]} is typing...`;
+ else if (activeTypers.length > 1) typingText = `Several people are typing...`;
+
+ const isCallActiveInThisDM = activeCall && activeCall.targetKey === activeChannel;
+
+ return (
+ { e.preventDefault(); setIsDragging(true); }}
+ onDragLeave={() => setIsDragging(false)}
+ onDrop={handleDrop}
+ className="flex-1 flex flex-col bg-panel min-w-0 relative h-full"
+ >
+ {isDragging && (
+
+
+
+
Drop files to upload
+
+
+ )}
+
+ {expandedImage && (
+
setExpandedImage(null)}>
+

e.stopPropagation()} />
+
+
+ )}
+
+ {contextMenu && (
+
setContextMenu(null)} onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}>
+
e.stopPropagation()}
+ >
+ {contextMenu.isMe && contextMenu.msg.payload?.type !== 'server_invite' && (
+
+ )}
+ {(contextMenu.isAdmin || contextMenu.isMe) && (
+
+ )}
+
+
+ )}
+
+
+
{headerIcon}
+
{headerName}
+
+
+ {isDMView && !isGroupChat && (
+ <>
+
+
+ >
+ )}
+ {(isDMView || isGroupChat) && (
+ <>
+ {isGroupChat && (
+
+ )}
+ {isCallActiveInThisDM ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+ >
+ )}
+ {(!isDMView || isGroupChat) && (
+
+ )}
+
+
+
+
+
+
+
{headerIcon}
+
{isGroupChat ? `Welcome to ${headerName}!` : (isDMView ? headerName : `Welcome to #${headerName}!`)}
+
+ {isGroupChat ? `This is the beginning of your group whisper history.` : (isDMView ? `This is the beginning of your whisper history with ${headerName}.` : `This is the start of the decentralized #${headerName} room.`)}
+
+
+
+ {currentChannelMessages.map((msg) => {
+ const isPlatformAdmin = msg.sender === ADMIN_PUBLIC_KEY;
+ const isServerOwner = (!isDMView || isGroupChat) && servers.find(s=>s.topicHex===(isGroupChat ? activeChannel : activeView))?.owner === msg.sender;
+ const showCrown = isPlatformAdmin || isServerOwner;
+ const crownTitle = isServerOwner ? (isGroupChat ? "Group Creator" : "Hub Owner") : "Platform Admin";
+ const isMe = msg.sender === myKey;
+
+ return (
+
{
+ if (isMe || isAdmin) {
+ e.preventDefault();
+ setContextMenu({ x: e.pageX, y: e.pageY, msg, isMe, isAdmin });
+ }
+ }}
+ >
+
+ {msg.senderAvatar ?

: msg.senderName.substring(0, 2).toUpperCase()}
+
+
+
+
+
+ {isMe ? `${profile.displayName} (You)` : msg.senderName}
+ {showCrown && 👑}
+
+ {new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+ {getMessageStatus(msg)}
+
+
+
+ {showCrypto && msg.isEncrypted ? (
+
+
Algorithm: xchacha20poly1305_ietf
+
Nonce: {msg.nonce}
+
{msg.cipher}
+
+ ) : editingId === msg.id ? (
+
+
+ ) : msg.payload?.type === 'server_invite' ? (
+
+
+
+ ) : msg.payload?.type === 'file' ? (
+
+ {msg.text && (
+
+ )}
+
+ {(() => {
+ const fileMeta = msg.payload.file;
+ const transfer = transfers[msg.id];
+ const isImage = fileMeta.mimeType?.startsWith('image/');
+ const isVideo = fileMeta.mimeType?.startsWith('video/');
+
+ const isComplete = !!msg.localPath || !!msg.localBlobUrl;
+ const isSender = msg.sender === myKey;
+
+ const stateText = transfer ? (
+ transfer.state === 'processing' ? 'Processing Local File...' :
+ transfer.state === 'uploading' ? 'Uploading to Peer...' :
+ transfer.state === 'downloading' ? 'Downloading...' : 'Complete'
+ ) : 'Waiting for peer...';
+
+ if (!isComplete) {
+ return (
+
+
+ {fileMeta.name}
+ {transfer ? Math.round(transfer.progress * 100) + '%' : '0%'}
+
+
+
+
{formatBytes(fileMeta.size)}
+
+ {stateText}
+ {transfer && transfer.state !== 'completed' && transfer.state !== 'processing' && (
+ • {formatBytes(transfer.speed)}/s
+ )}
+
+
+
+ );
+ } else {
+ if (isImage || isVideo) {
+ const fileUrl = msg.localBlobUrl ? msg.localBlobUrl : `peercord://local/${encodeURIComponent(msg.localPath.replace(/\\/g, '/'))}`;
+
+ return (
+
+ {isImage && (
+

setExpandedImage(fileUrl)} />
+ )}
+ {isVideo && (
+
+ )}
+ {transfer && transfer.state === 'uploading' && transfer.progress < 1 && (
+
+
+
{transfer.progress > 0 ? `Uploading to peer... ${Math.round(transfer.progress * 100)}% • ${formatBytes(transfer.speed)}/s` : 'Seeding file...'}
+
+ )}
+
+ );
+ } else {
+ if (isSender) {
+ return (
+
+
+
+
+ {fileMeta.name}
+ {formatBytes(fileMeta.size)} • Sent File
+
+
+ {transfer && transfer.state === 'uploading' && transfer.progress < 1 && (
+
+
+
{transfer.progress > 0 ? `Uploading to peer... ${Math.round(transfer.progress * 100)}% • ${formatBytes(transfer.speed)}/s` : 'Seeding file...'}
+
+ )}
+
+ );
+ } else {
+ return (
+
+
handleOpenFolder(msg.localPath)}>
+
+
+ {fileMeta.name}
+ {formatBytes(fileMeta.size)} • Click to show in folder
+
+
+
+ );
+ }
+ }
+ }
+ })()}
+
+ ) : (
+
+
+
+ )}
+ {msg.edited && !showCrypto &&
(edited)}
+
+
+
+ {/* Actions */}
+
+ {isMe && msg.payload?.type !== 'server_invite' && (
+
+ )}
+ {(isAdmin || isMe) && (
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+
+ {typingText && (
+ <>{typingText}>
+ )}
+
+
+ {attachments.length > 0 && (
+
+ {attachments.map((att, i) => (
+
+
+ {att.preview ? (
+

+ ) : (
+
+ )}
+
+ ))}
+
+ )}
+
+
0 ? 'rounded-b-lg' : 'rounded-lg'}`}>
+ processFiles(e.target.files)} />
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/ConsoleOverlay.jsx b/Peercord Source/src/components/ConsoleOverlay.jsx
new file mode 100644
index 0000000..c6fa565
--- /dev/null
+++ b/Peercord Source/src/components/ConsoleOverlay.jsx
@@ -0,0 +1,61 @@
+import React, { useEffect, useRef, useState } from 'react';
+
+export default function ConsoleOverlay({ logs, onClose }) {
+ const endRef = useRef(null);
+ const [copied, setCopied] = useState(false);
+
+ useEffect(() => {
+ endRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [logs]);
+
+ const handleCopy = () => {
+ const text = logs.map(l => `[${l.time}] ${l.msg}`).join('\n');
+ navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+
+
+
+ Developer Console (Press F10 to toggle)
+
+
+
+
+
+
+
+ {logs.length === 0 && (
+
No logs captured yet...
+ )}
+ {logs.map((log, i) => (
+
+ [{log.time}]
+ {log.msg}
+
+ ))}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/CreateChannelModal.jsx b/Peercord Source/src/components/CreateChannelModal.jsx
new file mode 100644
index 0000000..568030d
--- /dev/null
+++ b/Peercord Source/src/components/CreateChannelModal.jsx
@@ -0,0 +1,61 @@
+import React, { useState } from 'react';
+
+export default function CreateChannelModal({ onClose, onSave, defaultType = 'text' }) {
+ const [name, setName] = useState('');
+ const [type, setType] = useState(defaultType);
+
+ const handleSave = () => {
+ const cleanName = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
+ if (cleanName) onSave(cleanName, type);
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
Create Channel
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {type === 'text' ? '#' : '🔊'}
+ setName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'))}
+ placeholder="new-channel"
+ className="w-full bg-panel text-text rounded p-2.5 pl-8 outline-none focus:ring-1 focus:ring-accent text-sm"
+ autoFocus
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/CreateGroupModal.jsx b/Peercord Source/src/components/CreateGroupModal.jsx
new file mode 100644
index 0000000..57de32e
--- /dev/null
+++ b/Peercord Source/src/components/CreateGroupModal.jsx
@@ -0,0 +1,119 @@
+import React, { useState } from 'react';
+
+export default function CreateGroupModal({ onClose, onSave, dms }) {
+ const[name, setName] = useState('');
+ const [selected, setSelected] = useState(new Set());
+ const[searchQuery, setSearchQuery] = useState('');
+
+ const friends = Object.entries(dms)
+ .filter(([_, data]) => data.status === 'accepted')
+ .filter(([_, data]) =>
+ data.profile?.displayName?.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ const toggleSelect = (friendKey) => {
+ const next = new Set(selected);
+ if (next.has(friendKey)) {
+ next.delete(friendKey);
+ } else {
+ if (next.size < 49) next.add(friendKey);
+ }
+ setSelected(next);
+ };
+
+ const handleSave = () => {
+ if (selected.size === 0) return;
+ let finalName = name.trim();
+ if (!finalName) {
+ finalName = Array.from(selected)
+ .map(k => dms[k].profile?.displayName || 'Unknown')
+ .slice(0, 4)
+ .join(', ');
+ }
+ onSave(finalName, Array.from(selected));
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
+
+
Create Group Whisper
+
+
+
+
+
+ setName(e.target.value)}
+ className="w-full bg-panel text-text rounded p-2 outline-none focus:ring-1 focus:ring-accent text-sm mb-2"
+ maxLength={32}
+ />
+
+
+
+ setSearchQuery(e.target.value)}
+ className="w-full bg-panel text-text rounded p-2 pl-8 outline-none focus:ring-1 focus:ring-accent text-sm"
+ />
+
+
+
+
+
+
+ {friends.length === 0 ? (
+
+ {searchQuery ? "No contacts found matching that name." : "You don't have any contacts to add yet."}
+
+ ) : (
+ friends.map(([friendKey, data]) => {
+ const isSelected = selected.has(friendKey);
+
+ return (
+
toggleSelect(friendKey)}
+ className="flex items-center justify-between group hover:bg-panel p-2 rounded transition-colors cursor-pointer"
+ >
+
+
+ {data.profile?.avatar ?

: data.profile?.displayName?.substring(0, 2).toUpperCase()}
+
+
{data.profile?.displayName}
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
+ {selected.size}/49 Selected
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/CreateServerModal.jsx b/Peercord Source/src/components/CreateServerModal.jsx
new file mode 100644
index 0000000..d0cde37
--- /dev/null
+++ b/Peercord Source/src/components/CreateServerModal.jsx
@@ -0,0 +1,107 @@
+import React, { useState, useRef } from 'react';
+
+export default function CreateServerModal({ onClose, onSave }) {
+ const [serverName, setServerName] = useState('');
+ const [serverIcon, setServerIcon] = useState(null);
+ const[allowAnyone, setAllowAnyone] = useState(true);
+ const fileInputRef = useRef(null);
+
+ const handleImageUpload = (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ const img = new Image();
+ img.onload = () => {
+ const canvas = document.createElement('canvas');
+ const MAX_SIZE = 128;
+ let width = img.width;
+ let height = img.height;
+
+ if (width > height) {
+ if (width > MAX_SIZE) { height *= MAX_SIZE / width; width = MAX_SIZE; }
+ } else {
+ if (height > MAX_SIZE) { width *= MAX_SIZE / height; height = MAX_SIZE; }
+ }
+
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(img, 0, 0, width, height);
+
+ const dataUrl = canvas.toDataURL(file.type === 'image/png' ? 'image/png' : 'image/jpeg', 0.8);
+ setServerIcon(dataUrl);
+ };
+ img.src = event.target.result;
+ };
+ reader.readAsDataURL(file);
+ };
+
+ const handleCreate = () => {
+ if (serverName.trim() === '') return;
+ onSave(serverName.trim(), serverIcon, allowAnyone);
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
Create Your Hub
+
+ Give your new hub a personality with a name and an icon.
+
+
+
+
fileInputRef.current?.click()}
+ >
+ {serverIcon ? (
+

+ ) : (
+
+ )}
+
+
+
+
+
+
setServerName(e.target.value)}
+ className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-2 focus:ring-accent mb-4"
+ placeholder="e.g. My Cool Club"
+ maxLength={32}
+ autoFocus
+ />
+
+
+
+ setAllowAnyone(e.target.checked)}
+ className="w-5 h-5 accent-accent cursor-pointer"
+ />
+ Anyone can invite people to this hub
+
+
If unchecked, only you (the Admin) can send invites.
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/DMList.jsx b/Peercord Source/src/components/DMList.jsx
new file mode 100644
index 0000000..d0b5e11
--- /dev/null
+++ b/Peercord Source/src/components/DMList.jsx
@@ -0,0 +1,229 @@
+import React, { useState, useEffect } from 'react';
+import { 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);
+
+ useEffect(() => {
+ const interval = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(interval);
+ },[]);
+
+ useEffect(() => {
+ const handleClick = () => setContextMenu(null);
+ if (contextMenu) document.addEventListener('click', handleClick);
+ return () => document.removeEventListener('click', handleClick);
+ },[contextMenu]);
+
+ const acceptedDMs = Object.entries(dms).filter(([_, data]) => data.status === 'accepted');
+ const pendingIncoming = Object.entries(dms).filter(([_, data]) => data.status === 'pending_incoming');
+ const groupChats = servers.filter(s => s.isGroupChat);
+
+ const renderDM = (pubKey, data) => {
+ const isActive = activeChannel === pubKey;
+ const unread = unreadCounts[pubKey] || 0;
+ const hasUnread = unread > 0 && !isActive;
+ const dmProfile = data.profile || { displayName: 'Unknown', username: 'unknown' };
+
+ const isOnline = onlinePeers.some(p => p.key === pubKey);
+ const isTyping = typingUsers[pubKey] &&
+ typingUsers[pubKey].channel === myKey &&
+ (now - typingUsers[pubKey].timestamp < 3000) &&
+ pubKey !== activeChannel;
+
+ return (
+ setActiveChannel(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'
+ }`}
+ >
+
+
+
+ {dmProfile.avatar ? (
+

+ ) : (
+ dmProfile.displayName.substring(0, 2).toUpperCase()
+ )}
+
+
+
+
+
{dmProfile.displayName}
+ {isTyping && (
+
+
+
+
+
+ )}
+
+
+ {hasUnread && !isTyping && (
+
+ {unread > 99 ? '99+' : unread}
+
+ )}
+
+ );
+ };
+
+ const renderGC = (gc) => {
+ const isActive = activeChannel === gc.topicHex;
+ const unread = unreadCounts[gc.topicHex] || 0;
+ const hasUnread = unread > 0 && !isActive;
+
+ return (
+ setActiveChannel(gc.topicHex)}
+ onContextMenu={(e) => {
+ e.preventDefault();
+ setContextMenu({ x: e.pageX, y: e.pageY, topicHex: gc.topicHex, isOwner: gc.owner === myKey });
+ }}
+ 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'
+ }`}
+ >
+
+
+
+ {gc.icon ? (
+

+ ) : (
+
+ )}
+
+
+
+ {gc.name}
+ Group Whisper
+
+
+ {hasUnread && (
+
+ {unread > 99 ? '99+' : unread}
+
+ )}
+
+ );
+ };
+
+ return (
+
+
+ {contextMenu && (
+
setContextMenu(null)} onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}>
+
e.stopPropagation()}
+ >
+ {contextMenu.isOwner ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+ {activeCall && (
+
+ )}
+
+
+
+
+
+
+
setActiveChannel('friends')}
+ className={`px-2 py-2 rounded cursor-pointer flex items-center justify-between ${activeChannel === 'friends' ? 'bg-panel text-text' : 'text-muted hover:bg-panel/50 hover:text-text'}`}
+ >
+
+ {pendingIncoming.length > 0 && (
+
+ {pendingIncoming.length}
+
+ )}
+
+
+
+ Whispers
+
+
+
+
+ {groupChats.map(gc => renderGC(gc))}
+ {acceptedDMs.map(([pubKey, data]) => renderDM(pubKey, data))}
+
+
+
+
+
+
+ {profile.avatar ? (
+

+ ) : (
+ profile.displayName.substring(0, 2).toUpperCase()
+ )}
+
+
+
+
+
+ {profile.displayName}
+ {myKey === ADMIN_PUBLIC_KEY && 👑}
+
+ @{profile.username}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/FriendsView.jsx b/Peercord Source/src/components/FriendsView.jsx
new file mode 100644
index 0000000..eb4de5b
--- /dev/null
+++ b/Peercord Source/src/components/FriendsView.jsx
@@ -0,0 +1,137 @@
+import React, { useState } from 'react';
+import { network } from '../p2p/index.js';
+
+export default function FriendsView({ dms }) {
+ const [activeTab, setActiveTab] = useState('pending');
+ const [searchUsername, setSearchUsername] = useState('');
+ const[searchStatus, setSearchStatus] = useState(''); // 'searching', 'queued', 'found', 'error'
+
+ const pendingIncoming = Object.entries(dms).filter(([_, data]) => data.status === 'pending_incoming');
+ const pendingOutgoing = Object.entries(dms).filter(([_, data]) => data.status === 'pending_outgoing');
+
+ const handleAddFriend = async (e) => {
+ e.preventDefault();
+ const target = searchUsername.trim().toLowerCase();
+ if (!target) return;
+ if (target === network.username) {
+ setSearchStatus('error');
+ return;
+ }
+
+ setSearchStatus('searching');
+
+ const result = await network.searchUser(target);
+
+ if (result) {
+ await network.sendDMRequest(result.pubKey, result.profile);
+ setSearchStatus('found');
+ setSearchUsername('');
+ } else {
+ await network.queueFriendRequest(target);
+ setSearchStatus('queued');
+ setSearchUsername('');
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {activeTab === 'pending' && (
+
+
Pending Requests — {pendingIncoming.length + pendingOutgoing.length}
+
+
+ {pendingIncoming.map(([pubKey, data]) => (
+
+
+
+ {data.profile?.avatar ?

: data.profile?.displayName?.substring(0, 2).toUpperCase()}
+
+
+ {data.profile?.displayName}
+ @{data.profile?.username} • Incoming Contact Request
+
+
+
+
+
+
+ ))}
+
+ {pendingOutgoing.map(([pubKey, data]) => (
+
+
+
+ {data.profile?.avatar ?

: data.profile?.displayName?.substring(0, 2).toUpperCase()}
+
+
+ {data.profile?.displayName}
+ @{data.profile?.username} • Outgoing Contact Request
+
+
+
+ ))}
+
+ {pendingIncoming.length === 0 && pendingOutgoing.length === 0 && (
+
No pending requests.
+ )}
+
+
+ )}
+
+ {activeTab === 'add' && (
+
+
ADD CONTACT
+
You can add a contact with their username. It's case sensitive!
+
+
+
+ {searchStatus === 'searching' &&
Searching network...
}
+ {searchStatus === 'found' &&
Success! Your contact request was sent.
}
+ {searchStatus === 'queued' &&
User is currently offline. We queued your request and will send it automatically when they come online!
}
+ {searchStatus === 'error' &&
You cannot send a contact request to yourself.
}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/GroupCallView.jsx b/Peercord Source/src/components/GroupCallView.jsx
new file mode 100644
index 0000000..1665681
--- /dev/null
+++ b/Peercord Source/src/components/GroupCallView.jsx
@@ -0,0 +1,635 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { network } from '../p2p/index.js';
+import ScreenShareModal from './ScreenShareModal.jsx';
+
+const VideoPlayer = ({ stream, muted, isAudioOnly }) => {
+ const ref = useRef();
+ useEffect(() => {
+ if (ref.current && stream) {
+ ref.current.srcObject = stream;
+ const outputId = localStorage.getItem('pear_audio_output');
+ if (outputId && outputId !== 'default' && ref.current.setSinkId) {
+ ref.current.setSinkId(outputId).catch(console.error);
+ }
+ }
+ }, [stream]);
+
+ if (isAudioOnly) {
+ return ;
+ }
+ return ;
+};
+
+export default function GroupCallView({ channel, serverTopicHex, vcChannelId, myKey, myProfile, knownUsers, onClose, onToggleChat, onLocalStateChange, className, initialVideoOn }) {
+ const [isMuted, setIsMuted] = useState(false);
+ const [isVideoOn, setIsVideoOn] = useState(initialVideoOn || false);
+ const [localVoiceActive, setLocalVoiceActive] = useState(false);
+ const [showScreenShareModal, setShowScreenShareModal] = useState(false);
+ const [isScreenSharing, setIsScreenSharing] = useState(false);
+ const [expandedStreamId, setExpandedStreamId] = useState(null);
+
+ // { [peerKey]: { streams: MediaStream[], voiceActive: boolean } }
+ const [peers, setPeers] = useState({});
+
+ const pcs = useRef({});
+ const pendingCandidates = useRef({});
+ const makingOffer = useRef({});
+ const ignoreOffer = useRef({});
+
+ const localStreamRef = useRef(null);
+ const localScreenStreamRef = useRef(null);
+ const localCameraStreamRef = useRef(null);
+ const audioCtxRef = useRef(null);
+ const animationFrameRef = useRef(null);
+
+ // Broadcast VC state to the server swarm if this is a server VC
+ useEffect(() => {
+ if (serverTopicHex && vcChannelId) {
+ const broadcastState = () => {
+ network.sendEphemeral({
+ type: 'vc-state',
+ serverTopicHex,
+ channel: vcChannelId,
+ muted: isMuted,
+ screenshare: isScreenSharing
+ });
+ if (onLocalStateChange) onLocalStateChange(isMuted, isScreenSharing);
+ };
+
+ broadcastState(); // Initial broadcast
+ const interval = setInterval(broadcastState, 3000);
+
+ return () => {
+ clearInterval(interval);
+ // Leave is handled by onClose in MainApp, but we also send it here on unmount just in case
+ network.sendEphemeral({
+ type: 'vc-leave',
+ serverTopicHex,
+ channel: vcChannelId
+ });
+ };
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[serverTopicHex, vcChannelId, isMuted, isScreenSharing]);
+
+ const sendSignal = (targetKey, signal) => {
+ network.sendWebRTCSignal(targetKey, { type: 'webrtc-group-signal', channel, target: targetKey, signal });
+ };
+
+ const createPC = (peerKey) => {
+ const pc = new RTCPeerConnection({ iceServers:[{ urls: 'stun:stun.l.google.com:19302' }] });
+ makingOffer.current[peerKey] = false;
+ ignoreOffer.current[peerKey] = false;
+
+ if (localStreamRef.current) {
+ localStreamRef.current.getTracks().forEach(t => pc.addTrack(t, localStreamRef.current));
+ }
+ if (localScreenStreamRef.current) {
+ localScreenStreamRef.current.getTracks().forEach(t => pc.addTrack(t, localScreenStreamRef.current));
+ }
+ if (localCameraStreamRef.current) {
+ localCameraStreamRef.current.getTracks().forEach(t => pc.addTrack(t, localCameraStreamRef.current));
+ }
+
+ pc.onicecandidate = (e) => {
+ if (e.candidate) sendSignal(peerKey, { type: 'ice', candidate: e.candidate });
+ };
+
+ pc.ontrack = (e) => {
+ setPeers(prev => {
+ const existing = prev[peerKey] || { streams:[], voiceActive: false };
+ const streamExists = existing.streams.find(s => s.id === e.streams[0].id);
+ if (!streamExists) {
+ return { ...prev, [peerKey]: { ...existing, streams:[...existing.streams, e.streams[0]] } };
+ }
+ return prev;
+ });
+ };
+
+ // Perfect Negotiation: Anyone can create an offer when needed
+ pc.onnegotiationneeded = async () => {
+ try {
+ makingOffer.current[peerKey] = true;
+ const offer = await pc.createOffer();
+ await pc.setLocalDescription(offer);
+ sendSignal(peerKey, { type: 'offer', sdp: pc.localDescription });
+ } catch (err) {
+ console.error("Failed to create offer:", err);
+ } finally {
+ makingOffer.current[peerKey] = false;
+ }
+ };
+
+ pcs.current[peerKey] = pc;
+ return pc;
+ };
+
+ useEffect(() => {
+ const setupMedia = async () => {
+ try {
+ const audioInputId = localStorage.getItem('pear_audio_input');
+ const aStream = await navigator.mediaDevices.getUserMedia({
+ audio: audioInputId && audioInputId !== 'default' ? { deviceId: { exact: audioInputId } } : true
+ });
+ localStreamRef.current = aStream;
+
+ if (initialVideoOn) {
+ try {
+ const videoInputId = localStorage.getItem('pear_video_input');
+ const vStream = await navigator.mediaDevices.getUserMedia({
+ video: videoInputId && videoInputId !== 'default' ? { deviceId: { exact: videoInputId } } : true
+ });
+ localCameraStreamRef.current = vStream;
+ } catch (err) {
+ console.error("Failed to get video", err);
+ setIsVideoOn(false);
+ }
+ }
+
+ const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+ audioCtxRef.current = audioCtx;
+ const analyser = audioCtx.createAnalyser();
+ const source = audioCtx.createMediaStreamSource(aStream);
+ source.connect(analyser);
+ analyser.fftSize = 256;
+ const bufferLength = analyser.frequencyBinCount;
+ const dataArray = new Uint8Array(bufferLength);
+
+ let lastSpeakingState = false;
+ const checkAudio = () => {
+ analyser.getByteFrequencyData(dataArray);
+ let sum = 0;
+ for (let i = 0; i < bufferLength; i++) sum += dataArray[i];
+ const average = sum / bufferLength;
+ const isSpeaking = average > 15;
+
+ if (isSpeaking !== lastSpeakingState) {
+ network.sendEphemeral({ type: 'webrtc-group-voice', channel, state: isSpeaking ? 'speaking' : 'silent' });
+ setLocalVoiceActive(isSpeaking);
+ lastSpeakingState = isSpeaking;
+ }
+ animationFrameRef.current = requestAnimationFrame(checkAudio);
+ };
+ checkAudio();
+
+ // Broadcast join to the GC or VC mesh
+ network.sendEphemeral({ type: 'webrtc-group-join', channel });
+
+ } catch (err) {
+ console.error("Failed to access microphone:", err);
+ alert("Could not access microphone. Please check your permissions.");
+ onClose();
+ }
+ };
+
+ setupMedia();
+
+ // Cleanup dead streams periodically
+ const cleanupInterval = setInterval(() => {
+ setPeers(prev => {
+ let changed = false;
+ const next = {};
+ for (const [key, peer] of Object.entries(prev)) {
+ const activeStreams = peer.streams.filter(s => s.active && s.getTracks().length > 0);
+ if (activeStreams.length !== peer.streams.length) changed = true;
+ next[key] = { ...peer, streams: activeStreams };
+ }
+ return changed ? next : prev;
+ });
+ }, 2000);
+
+ return () => {
+ clearInterval(cleanupInterval);
+ if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
+ if (audioCtxRef.current) audioCtxRef.current.close().catch(() => {});
+ if (localStreamRef.current) localStreamRef.current.getTracks().forEach(t => t.stop());
+ if (localScreenStreamRef.current) localScreenStreamRef.current.getTracks().forEach(t => t.stop());
+ if (localCameraStreamRef.current) localCameraStreamRef.current.getTracks().forEach(t => t.stop());
+
+ Object.values(pcs.current).forEach(pc => pc.close());
+ pcs.current = {};
+
+ network.sendEphemeral({ type: 'webrtc-group-leave', channel });
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[]);
+
+ useEffect(() => {
+ const handleSignal = async (peerKey, payload) => {
+ if (payload.channel !== channel) return;
+
+ if (payload.type === 'webrtc-group-join' && peerKey !== myKey) {
+ if (!pcs.current[peerKey]) createPC(peerKey);
+ // Send a ping back so the other peer knows we are here if they joined earlier
+ network.sendEphemeral({ type: 'webrtc-group-hello', channel, target: peerKey });
+ }
+ else if (payload.type === 'webrtc-group-hello' && payload.target === myKey) {
+ if (!pcs.current[peerKey]) createPC(peerKey);
+ }
+ else if (payload.type === 'webrtc-group-leave') {
+ if (pcs.current[peerKey]) {
+ pcs.current[peerKey].close();
+ delete pcs.current[peerKey];
+ }
+ setPeers(prev => {
+ const next = { ...prev };
+ delete next[peerKey];
+ return next;
+ });
+ }
+ else if (payload.type === 'webrtc-group-voice') {
+ setPeers(prev => {
+ if (!prev[peerKey]) return prev;
+ return { ...prev, [peerKey]: { ...prev[peerKey], voiceActive: payload.state === 'speaking' } };
+ });
+ }
+ else if (payload.type === 'webrtc-group-signal' && payload.target === myKey) {
+ const { signal } = payload;
+ let pc = pcs.current[peerKey];
+
+ if (!pc) pc = createPC(peerKey);
+
+ if (signal.type === 'offer' || signal.type === 'answer') {
+ // Perfect Negotiation Collision Resolution
+ const isPolite = myKey < peerKey;
+ const offerCollision = signal.type === 'offer' && (makingOffer.current[peerKey] || pc.signalingState !== 'stable');
+
+ ignoreOffer.current[peerKey] = !isPolite && offerCollision;
+ if (ignoreOffer.current[peerKey]) {
+ return;
+ }
+
+ try {
+ await pc.setRemoteDescription(signal.sdp);
+ if (signal.type === 'offer') {
+ const answer = await pc.createAnswer();
+ await pc.setLocalDescription(answer);
+ sendSignal(peerKey, { type: 'answer', sdp: pc.localDescription });
+ }
+
+ if (pendingCandidates.current[peerKey]) {
+ for (const c of pendingCandidates.current[peerKey]) {
+ await pc.addIceCandidate(c).catch(console.error);
+ }
+ pendingCandidates.current[peerKey] =[];
+ }
+ } catch (err) {
+ console.error("Error setting remote description:", err);
+ }
+ }
+ else if (signal.type === 'ice') {
+ try {
+ if (pc && pc.remoteDescription) {
+ await pc.addIceCandidate(signal.candidate);
+ } else {
+ if (!pendingCandidates.current[peerKey]) pendingCandidates.current[peerKey] =[];
+ pendingCandidates.current[peerKey].push(signal.candidate);
+ }
+ } catch (err) {
+ if (!ignoreOffer.current[peerKey]) {
+ console.error("Error adding ICE candidate:", err);
+ }
+ }
+ }
+ }
+ };
+
+ network.addWebRTCListener(handleSignal);
+ return () => network.removeWebRTCListener(handleSignal);
+ },[channel, myKey]);
+
+ const toggleMute = () => {
+ if (localStreamRef.current) {
+ const audioTrack = localStreamRef.current.getAudioTracks()[0];
+ if (audioTrack) {
+ audioTrack.enabled = !audioTrack.enabled;
+ setIsMuted(!audioTrack.enabled);
+ }
+ }
+ };
+
+ const toggleVideo = async () => {
+ if (isVideoOn) {
+ if (localCameraStreamRef.current) {
+ const track = localCameraStreamRef.current.getVideoTracks()[0];
+ Object.values(pcs.current).forEach(pc => {
+ const sender = pc.getSenders().find(s => s.track === track);
+ if (sender) pc.removeTrack(sender);
+ });
+ track.stop();
+ localCameraStreamRef.current = null;
+ }
+ setIsVideoOn(false);
+ } else {
+ try {
+ const videoInputId = localStorage.getItem('pear_video_input');
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: videoInputId && videoInputId !== 'default' ? { deviceId: { exact: videoInputId } } : true
+ });
+ localCameraStreamRef.current = stream;
+ const track = stream.getVideoTracks()[0];
+ Object.values(pcs.current).forEach(pc => {
+ pc.addTrack(track, stream);
+ });
+ setIsVideoOn(true);
+ } catch (err) {
+ console.error("Failed to start video", err);
+ }
+ }
+ };
+
+ const startScreenShare = async (sourceId, res, fps) => {
+ setShowScreenShareModal(false);
+ let stream = null;
+
+ try {
+ if (sourceId === 'native') {
+ stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
+ } else {
+ try {
+ stream = await navigator.mediaDevices.getUserMedia({
+ audio: false,
+ video: {
+ mandatory: {
+ chromeMediaSource: 'desktop',
+ chromeMediaSourceId: sourceId,
+ maxWidth: res.width,
+ maxHeight: res.height,
+ maxFrameRate: fps
+ }
+ }
+ });
+ } catch (initialErr) {
+ stream = await navigator.mediaDevices.getUserMedia({
+ audio: false,
+ video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: sourceId } }
+ });
+ }
+ }
+
+ const videoTrack = stream.getVideoTracks()[0];
+ videoTrack.contentHint = 'motion';
+ videoTrack.onended = () => stopScreenShare();
+
+ localScreenStreamRef.current = stream;
+ setIsScreenSharing(true);
+
+ // Add track to all existing peer connections
+ Object.values(pcs.current).forEach(pc => {
+ const sender = pc.addTrack(videoTrack, stream);
+ try {
+ const params = sender.getParameters();
+ if (!params.encodings || params.encodings.length === 0) params.encodings = [{}];
+ params.encodings[0].maxFramerate = fps;
+
+ let maxBitrate = 8000000;
+ if (res.height <= 720) maxBitrate = 4000000;
+ if (res.height <= 480) maxBitrate = 1500000;
+ if (res.height <= 360) maxBitrate = 800000;
+
+ params.encodings[0].maxBitrate = maxBitrate;
+ if ('degradationPreference' in params) params.degradationPreference = 'maintain-framerate';
+ sender.setParameters(params);
+ } catch (e) {}
+ });
+
+ } catch (err) {
+ console.error("Screen share failed", err);
+ if (stream) stream.getTracks().forEach(t => { t.enabled = false; t.stop(); });
+ setIsScreenSharing(false);
+ }
+ };
+
+ const stopScreenShare = async () => {
+ if (localScreenStreamRef.current) {
+ const trackToRemove = localScreenStreamRef.current.getVideoTracks()[0];
+
+ Object.values(pcs.current).forEach(pc => {
+ const sender = pc.getSenders().find(s => s.track === trackToRemove);
+ if (sender) pc.removeTrack(sender);
+ });
+
+ localScreenStreamRef.current.getTracks().forEach(t => { t.enabled = false; t.stop(); });
+ localScreenStreamRef.current = null;
+ setIsScreenSharing(false);
+
+ if (expandedStreamId === 'local-screen') {
+ setExpandedStreamId(null);
+ }
+ }
+ };
+
+ // Flatten all streams for the grid
+ const gridItems = [];
+
+ // Local User
+ if (isVideoOn && localCameraStreamRef.current) {
+ gridItems.push({
+ id: 'local-user',
+ isLocal: true,
+ name: `${myProfile.displayName} (You)`,
+ stream: localCameraStreamRef.current,
+ isAudioOnly: false,
+ voiceActive: localVoiceActive && !isMuted
+ });
+ } else {
+ gridItems.push({
+ id: 'local-user',
+ isLocal: true,
+ name: `${myProfile.displayName} (You)`,
+ avatar: myProfile.avatar,
+ voiceActive: localVoiceActive && !isMuted,
+ stream: null,
+ isAudioOnly: true
+ });
+ }
+
+ // Local Screen Share
+ if (isScreenSharing && localScreenStreamRef.current) {
+ gridItems.push({
+ id: 'local-screen',
+ isLocal: true,
+ name: 'Your Screen',
+ stream: localScreenStreamRef.current,
+ isAudioOnly: false
+ });
+ }
+
+ // Remote Peers
+ Object.entries(peers).forEach(([peerKey, peer]) => {
+ const profile = knownUsers.find(u => u.key === peerKey) || { displayName: 'Unknown' };
+
+ let audioStream = null;
+
+ peer.streams.forEach((stream, i) => {
+ if (stream.getVideoTracks().length > 0) {
+ gridItems.push({
+ id: `${peerKey}-video-${i}`,
+ isLocal: false,
+ name: `${profile.displayName}'s Screen`,
+ stream: stream,
+ isAudioOnly: false
+ });
+ } else if (stream.getAudioTracks().length > 0) {
+ audioStream = stream;
+ }
+ });
+
+ // Always add their voice box (avatar)
+ gridItems.push({
+ id: `${peerKey}-voice`,
+ isLocal: false,
+ name: profile.displayName,
+ avatar: profile.avatar,
+ voiceActive: peer.voiceActive,
+ stream: audioStream,
+ isAudioOnly: true
+ });
+ });
+
+ return (
+
+
+ {/* Header */}
+
+
+ {vcChannelId ? `Voice Channel: ${vcChannelId}` : 'Group Call'}
+
+
+ Connected • {Object.keys(peers).length + 1} in call
+
+
+
+ {/* Main Call Area (Auto-sizing Grid) */}
+
+
+ {gridItems.map(item => (
+
+ {item.stream && !item.isAudioOnly ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+ {item.stream && item.isAudioOnly &&
}
+
+ {item.avatar ? (
+

+ ) : (
+ item.name.substring(0, 2).toUpperCase()
+ )}
+
+ >
+ )}
+
+ {item.name}
+
+
+ ))}
+
+
+
+ {/* Bottom Controls */}
+
+
+
+
+
+
+
+ {isScreenSharing ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {showScreenShareModal && (
+
setShowScreenShareModal(false)}
+ onStart={startScreenShare}
+ />
+ )}
+
+ {/* Fullscreen Expanded View */}
+ {expandedStreamId && (() => {
+ const expandedItem = gridItems.find(i => i.id === expandedStreamId);
+ if (!expandedItem) {
+ setExpandedStreamId(null);
+ return null;
+ }
+ return (
+ setExpandedStreamId(null)}>
+
+
+
+
+ {expandedItem.name}
+
+
+
+ );
+ })()}
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/IncomingCallModal.jsx b/Peercord Source/src/components/IncomingCallModal.jsx
new file mode 100644
index 0000000..ec81f28
--- /dev/null
+++ b/Peercord Source/src/components/IncomingCallModal.jsx
@@ -0,0 +1,92 @@
+import React, { useEffect } from 'react';
+
+export default function IncomingCallModal({ incomingCall, onAccept, onDecline }) {
+
+ useEffect(() => {
+ const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+ let interval;
+
+ const beep = (freq, startTime, duration) => {
+ const osc = audioCtx.createOscillator();
+ const gain = audioCtx.createGain();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(freq, startTime);
+
+ gain.gain.setValueAtTime(0, startTime);
+ gain.gain.linearRampToValueAtTime(0.1, startTime + 0.05);
+ gain.gain.linearRampToValueAtTime(0, startTime + duration);
+
+ osc.connect(gain);
+ gain.connect(audioCtx.destination);
+ osc.start(startTime);
+ osc.stop(startTime + duration);
+ };
+
+ const playRing = () => {
+ if (audioCtx.state === 'suspended') audioCtx.resume();
+ const now = audioCtx.currentTime;
+ beep(440, now, 0.4);
+ beep(523.25, now + 0.2, 0.4);
+ };
+
+ playRing();
+ interval = setInterval(playRing, 2000);
+
+ return () => {
+ clearInterval(interval);
+ audioCtx.close().catch(() => {});
+ };
+ },[]);
+
+ const isGroup = incomingCall.isGroup;
+ const isVideo = incomingCall.callType === 'video';
+ const title = isGroup ? incomingCall.gcName : incomingCall.profile.displayName;
+ const subtitle = isGroup ? `Group Whisper started by ${incomingCall.callerName}` : (isVideo ? 'INCOMING VIDEO CALL' : 'INCOMING VOICE CALL');
+ const avatar = isGroup ? null : incomingCall.profile.avatar;
+ const fallback = isGroup ? '👥' : (incomingCall.profile.displayName?.substring(0, 2).toUpperCase() || '?');
+
+ return (
+
+
+
+
+
+ {avatar ? (
+

+ ) : (
+ fallback
+ )}
+
+
{title}
+
+
+ {subtitle}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/InviteModal.jsx b/Peercord Source/src/components/InviteModal.jsx
new file mode 100644
index 0000000..6b3d7a0
--- /dev/null
+++ b/Peercord Source/src/components/InviteModal.jsx
@@ -0,0 +1,92 @@
+import React, { useState } from 'react';
+import { network } from '../p2p/index.js';
+
+export default function InviteModal({ onClose, serverTopicHex, dms, serverMembers, isGroupChat }) {
+ const[sentInvites, setSentInvites] = useState(new Set());
+ const[searchQuery, setSearchQuery] = useState('');
+
+ const membersSet = new Set(serverMembers[serverTopicHex] ||[]);
+
+ const friends = Object.entries(dms)
+ .filter(([_, data]) => data.status === 'accepted')
+ .filter(([_, data]) =>
+ data.profile?.displayName?.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ const handleInvite = (friendKey) => {
+ if (sentInvites.has(friendKey) || membersSet.has(friendKey)) return;
+
+ if (isGroupChat) {
+ network.sendGroupChatAdd(friendKey, serverTopicHex);
+ } else {
+ network.sendServerInvite(friendKey, serverTopicHex);
+ }
+
+ setSentInvites(prev => new Set(prev).add(friendKey));
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
+
+
{isGroupChat ? 'Add contacts to Group Whisper' : 'Invite contacts'}
+
+
+
+ setSearchQuery(e.target.value)}
+ className="w-full bg-panel text-text rounded p-2 pl-8 outline-none focus:ring-1 focus:ring-accent text-sm"
+ autoFocus
+ />
+
+
+
+
+
+
+ {friends.length === 0 ? (
+
+ {searchQuery ? "No contacts found matching that name." : "You don't have any contacts to invite yet. Add some from the Whispers tab!"}
+
+ ) : (
+ friends.map(([friendKey, data]) => {
+ const isSent = sentInvites.has(friendKey);
+ const isMember = membersSet.has(friendKey);
+
+ return (
+
+
+
+ {data.profile?.avatar ?

: data.profile?.displayName?.substring(0, 2).toUpperCase()}
+
+
{data.profile?.displayName}
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/MainApp.jsx b/Peercord Source/src/components/MainApp.jsx
new file mode 100644
index 0000000..47ebd32
--- /dev/null
+++ b/Peercord Source/src/components/MainApp.jsx
@@ -0,0 +1,706 @@
+import React, { useEffect, useState, useRef } from 'react';
+import { network, ADMIN_PUBLIC_KEY } from '../p2p/index.js';
+import Sidebar from './Sidebar.jsx';
+import ChannelList from './ChannelList.jsx';
+import DMList from './DMList.jsx';
+import ChatArea from './ChatArea.jsx';
+import FriendsView from './FriendsView.jsx';
+import OnlineUsers from './OnlineUsers.jsx';
+import ProfileSettingsModal from './ProfileSettingsModal.jsx';
+import CreateServerModal from './CreateServerModal.jsx';
+import CreateGroupModal from './CreateGroupModal.jsx';
+import InviteModal from './InviteModal.jsx';
+import ServerSettingsModal from './ServerSettingsModal.jsx';
+import CallView from './CallView.jsx';
+import GroupCallView from './GroupCallView.jsx';
+import IncomingCallModal from './IncomingCallModal.jsx';
+
+export default function MainApp({ profile, setProfile, onLogout, updateState, simulatedProgress, triggerRestart, onSystemUpdate }) {
+ const[myKey, setMyKey] = useState('');
+ const[onlinePeers, setOnlinePeers] = useState([]);
+ const[knownUsers, setKnownUsers] = useState([]);
+ const[messages, setMessages] = useState([]);
+ const[servers, setServers] = useState([]);
+ const[serverMembers, setServerMembers] = useState({});
+ const[isSyncing, setIsSyncing] = useState(false);
+ const[transfers, setTransfers] = useState({});
+
+ const[activeView, setActiveView] = useState('dms');
+ const[activeChannel, setActiveChannel] = useState('general-chat');
+ const[activeDm, setActiveDm] = useState('friends');
+
+ const[dms, setDms] = useState({});
+ const[typingUsers, setTypingUsers] = useState({});
+
+ const[readReceipts, setReadReceipts] = useState(() => JSON.parse(localStorage.getItem('pear_read_receipts') || '{}'));
+ const[deliveredReceipts, setDeliveredReceipts] = useState(() => JSON.parse(localStorage.getItem('pear_delivered_receipts') || '{}'));
+ const[lastRead, setLastRead] = useState(() => JSON.parse(localStorage.getItem('pear_last_read') || '{}'));
+
+ const[isSettingsOpen, setIsSettingsOpen] = useState(false);
+ const[isCreateServerOpen, setIsCreateServerOpen] = useState(false);
+ const[isCreateGroupOpen, setIsCreateGroupOpen] = useState(false);
+ const[inviteModalServer, setInviteModalServer] = useState(null);
+ const[settingsModalServer, setSettingsModalServer] = useState(null);
+
+ // Call States
+ const[activeCall, setActiveCall] = useState(null);
+ const[activeGroupCall, setActiveGroupCall] = useState(null);
+ const[activeVc, setActiveVc] = useState(null);
+ const[incomingCall, setIncomingCall] = useState(null);
+ const[showChatInCall, setShowChatInCall] = useState(false);
+ const callTimeoutRef = useRef(null);
+
+ const[vcStates, setVcStates] = useState({});
+ const[showMembersDrawer, setShowMembersDrawer] = useState(false);
+
+ const [isNetworkOnline, setIsNetworkOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true);
+
+ const initialized = useRef(false);
+
+ useEffect(() => {
+ const handleOnline = () => {
+ setIsNetworkOnline(true);
+ setOnlinePeers(network.getPeerList());
+ network.reconnect();
+ };
+ const handleOffline = () => {
+ setIsNetworkOnline(false);
+ setOnlinePeers([]);
+ };
+
+ window.addEventListener('online', handleOnline);
+ window.addEventListener('offline', handleOffline);
+
+ return () => {
+ window.removeEventListener('online', handleOnline);
+ window.removeEventListener('offline', handleOffline);
+ };
+ }, []);
+
+ useEffect(() => {
+ localStorage.setItem('pear_last_read', JSON.stringify(lastRead));
+ },[lastRead]);
+
+ useEffect(() => {
+ localStorage.setItem('pear_read_receipts', JSON.stringify(readReceipts));
+ },[readReceipts]);
+
+ useEffect(() => {
+ localStorage.setItem('pear_delivered_receipts', JSON.stringify(deliveredReceipts));
+ },[deliveredReceipts]);
+
+ useEffect(() => {
+ if (!initialized.current && typeof window !== 'undefined') {
+ initialized.current = true;
+
+ network.onInit = (key) => setMyKey(key);
+ network.onPeerUpdate = (peers) => {
+ if (typeof navigator !== 'undefined' && navigator.onLine) {
+ setOnlinePeers([...peers]);
+ }
+ };
+ network.onKnownProfilesUpdate = (users) => setKnownUsers(users);
+ network.onMessage = (msgs) => setMessages([...msgs]);
+ network.onDMsUpdate = (updatedDms) => setDms(updatedDms);
+ network.onTransfersUpdate = (t) => setTransfers({...t});
+ network.onServersUpdate = (srvs) => {
+ setServers([...srvs]);
+ setActiveView(prev => {
+ if (prev !== 'dms' && !srvs.some(s => s.topicHex === prev)) return 'dms';
+ return prev;
+ });
+ };
+ network.onServerMembersUpdate = (members) => setServerMembers(members);
+ network.onSync = (status) => setIsSyncing(status);
+
+ network.onEphemeral = (peerKey, payload) => {
+ if (payload.type === 'system_update') {
+ try {
+ const b4a = window.require('b4a');
+ const sodium = window.require('sodium-native');
+
+ const sigBuf = b4a.from(payload.signature, 'hex');
+ const msgBuf = b4a.from(payload.version + payload.timestamp);
+ const pubBuf = b4a.from(ADMIN_PUBLIC_KEY, 'hex');
+
+ const isValid = sodium.crypto_sign_verify_detached(sigBuf, msgBuf, pubBuf);
+
+ if (isValid && payload.version !== window.APP_VERSION) {
+ if (onSystemUpdate) onSystemUpdate(payload.version, payload);
+ } else if (!isValid) {
+ console.warn('[P2P] Received invalid update broadcast signature.');
+ }
+ } catch (e) {
+ console.error("Failed to verify update broadcast", e);
+ }
+ } else if (payload.type === 'typing') {
+ setTypingUsers(prev => ({
+ ...prev,[peerKey]: { channel: payload.channel, displayName: payload.displayName, timestamp: Date.now() }
+ }));
+ } else if (payload.type === 'read') {
+ const markChannel = payload.channel === network.myKey ? peerKey : payload.channel;
+ setReadReceipts(prev => ({ ...prev, [markChannel]: payload.messageId }));
+ } else if (payload.type === 'delivered') {
+ const markChannel = payload.channel === network.myKey ? peerKey : payload.channel;
+ setDeliveredReceipts(prev => ({ ...prev,[markChannel]: payload.messageId }));
+ } else if (payload.type === 'vc-state') {
+ setVcStates(prev => {
+ const serverVCS = prev[payload.serverTopicHex] || {};
+ const channelVCS = serverVCS[payload.channel] || {};
+ return {
+ ...prev,
+ [payload.serverTopicHex]: {
+ ...serverVCS,
+ [payload.channel]: {
+ ...channelVCS,
+ [peerKey]: {
+ muted: payload.muted,
+ screenshare: payload.screenshare,
+ timestamp: Date.now()
+ }
+ }
+ }
+ };
+ });
+ } else if (payload.type === 'vc-leave') {
+ setVcStates(prev => {
+ const serverVCS = prev[payload.serverTopicHex];
+ if (!serverVCS) return prev;
+ const channelVCS = serverVCS[payload.channel];
+ if (!channelVCS) return prev;
+
+ const newChannelVCS = { ...channelVCS };
+ delete newChannelVCS[peerKey];
+
+ return {
+ ...prev,
+ [payload.serverTopicHex]: {
+ ...serverVCS,
+ [payload.channel]: newChannelVCS
+ }
+ };
+ });
+ }
+ };
+
+ network.initialize(profile.seedHex, profile.displayName, profile.username, profile.avatar)
+ .catch(err => {
+ alert("P2P Initialization Error:\n" + err.message + "\n\nPress F12 to open DevTools for more info.");
+ console.error(err);
+ });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[]);
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ const now = Date.now();
+ setVcStates(prev => {
+ let changed = false;
+ const next = { ...prev };
+ for (const serverId in next) {
+ for (const channelId in next[serverId]) {
+ for (const peerKey in next[serverId][channelId]) {
+ if (now - next[serverId][channelId][peerKey].timestamp > 10000) {
+ delete next[serverId][channelId][peerKey];
+ changed = true;
+ }
+ }
+ }
+ }
+ return changed ? next : prev;
+ });
+ }, 5000);
+ return () => clearInterval(interval);
+ },[]);
+
+ useEffect(() => {
+ if (!myKey) return;
+ onlinePeers.forEach(peer => {
+ const msgsFromPeer = messages.filter(m => m.sender === peer.key && m.recipient === myKey);
+ if (msgsFromPeer.length > 0) {
+ const lastMsg = msgsFromPeer[msgsFromPeer.length - 1];
+ network.sendDeliveredReceipt(peer.key, lastMsg.id);
+ }
+ });
+ },[messages.length, onlinePeers.length, myKey]);
+
+ useEffect(() => {
+ network.activeCalls = (activeCall ? 1 : 0) + (activeGroupCall ? 1 : 0) + (activeVc ? 1 : 0);
+ },[activeCall, activeGroupCall, activeVc]);
+
+ useEffect(() => {
+ if (activeView === 'dms' && activeDm !== 'friends') {
+ const isUserDm = !!dms[activeDm];
+ const isGc = servers.some(s => s.topicHex === activeDm && s.isGroupChat);
+ if (!isUserDm && !isGc) {
+ setActiveDm('friends');
+ }
+ }
+ },[servers, dms, activeView, activeDm]);
+
+ useEffect(() => {
+ const handleWebRTC = (peerKey, payload) => {
+ if (payload.type === 'webrtc-init') {
+ if (!activeCall && !activeGroupCall && !activeVc) {
+ const callerProfile = knownUsers.find(u => u.key === peerKey) || dms[peerKey]?.profile || { displayName: 'Unknown' };
+ setIncomingCall({ isGroup: false, targetKey: peerKey, profile: callerProfile, callType: payload.callType || 'voice' });
+ } else {
+ network.sendWebRTCSignal(peerKey, { type: 'webrtc-busy' });
+ }
+ } else if (payload.type === 'webrtc-cancel') {
+ setIncomingCall(current => (current && !current.isGroup && current.targetKey === peerKey) ? null : current);
+ } else if (payload.type === 'webrtc-accept') {
+ setActiveCall(current => {
+ if (current?.targetKey === peerKey) {
+ if (callTimeoutRef.current) clearTimeout(callTimeoutRef.current);
+ return { ...current, status: 'connecting' };
+ }
+ return current;
+ });
+ } else if (payload.type === 'webrtc-decline' || payload.type === 'webrtc-busy') {
+ setActiveCall(current => {
+ if (current?.targetKey === peerKey) {
+ if (callTimeoutRef.current) clearTimeout(callTimeoutRef.current);
+ alert(payload.type === 'webrtc-busy' ? 'User is busy' : 'Call declined');
+ return null;
+ }
+ return current;
+ });
+ } else if (payload.type === 'webrtc-end') {
+ setActiveCall(current => current?.targetKey === peerKey ? null : current);
+ }
+ else if (payload.type === 'webrtc-group-ring') {
+ const gc = servers.find(s => s.topicHex === payload.channel && s.isGroupChat);
+ if (gc && activeGroupCall?.channel !== payload.channel && !activeVc) {
+ setIncomingCall({ isGroup: true, channel: payload.channel, callerName: payload.callerName, gcName: gc.name, callType: payload.callType || 'voice' });
+ }
+ }
+ };
+ network.addWebRTCListener(handleWebRTC);
+ return () => network.removeWebRTCListener(handleWebRTC);
+ },[activeCall, activeGroupCall, activeVc, knownUsers, dms, servers]);
+
+ const handleSaveProfile = (newName, newAvatar, newUsername) => {
+ const updatedProfile = { ...profile, displayName: newName, avatar: newAvatar, username: newUsername || profile.username };
+
+ const accounts = JSON.parse(localStorage.getItem('pear_saved_accounts') || '[]');
+ const existingIndex = accounts.findIndex(a => a.seedHex === profile.seedHex);
+ if (existingIndex >= 0) {
+ accounts[existingIndex] = updatedProfile;
+ localStorage.setItem('pear_saved_accounts', JSON.stringify(accounts));
+ }
+
+ localStorage.setItem('pear_discord_identity', JSON.stringify(updatedProfile));
+ setProfile(updatedProfile);
+ network.updateProfile(newName, newAvatar, newUsername);
+ setIsSettingsOpen(false);
+ };
+
+ const handleCreateServer = async (name, icon, allowAnyone) => {
+ const newServer = await network.createServer(name, icon, allowAnyone, false);
+ setIsCreateServerOpen(false);
+ setActiveView(newServer.topicHex);
+ };
+
+ const handleCreateGroup = async (name, members) => {
+ const gc = await network.createServer(name, null, true, true);
+ for (const key of members) {
+ await network.sendGroupChatAdd(key, gc.topicHex);
+ }
+ setIsCreateGroupOpen(false);
+ setActiveView('dms');
+ setActiveDm(gc.topicHex);
+ };
+
+ const endCall = () => {
+ if (activeCall) {
+ if (activeCall.status === 'ringing' && activeCall.isCaller) {
+ network.sendWebRTCSignal(activeCall.targetKey, { type: 'webrtc-cancel' });
+ } else {
+ network.sendWebRTCSignal(activeCall.targetKey, { type: 'webrtc-end' });
+ }
+ if (callTimeoutRef.current) clearTimeout(callTimeoutRef.current);
+ setActiveCall(null);
+ }
+ };
+
+ const startCall = (targetKey, callType = 'voice') => {
+ if (activeVc) {
+ network.sendEphemeral({ type: 'vc-leave', serverTopicHex: activeVc.serverId, channel: activeVc.channelId });
+ setVcStates(prev => {
+ const serverVCS = prev[activeVc.serverId];
+ if (!serverVCS) return prev;
+ const channelVCS = serverVCS[activeVc.channelId];
+ if (!channelVCS) return prev;
+ const newChannelVCS = { ...channelVCS };
+ delete newChannelVCS[myKey];
+ return { ...prev, [activeVc.serverId]: { ...serverVCS,[activeVc.channelId]: newChannelVCS } };
+ });
+ setActiveVc(null);
+ }
+ const targetProfile = dms[targetKey]?.profile || knownUsers.find(u => u.key === targetKey) || { displayName: 'Unknown' };
+ setActiveCall({ targetKey, profile: targetProfile, status: 'ringing', isCaller: true, callType });
+ setShowChatInCall(false);
+ network.sendWebRTCSignal(targetKey, { type: 'webrtc-init', callType });
+
+ if (callTimeoutRef.current) clearTimeout(callTimeoutRef.current);
+ callTimeoutRef.current = setTimeout(() => {
+ setActiveCall(current => {
+ if (current && current.targetKey === targetKey && current.status === 'ringing') {
+ network.sendWebRTCSignal(targetKey, { type: 'webrtc-cancel' });
+ return null;
+ }
+ return current;
+ });
+ }, 30000);
+ };
+
+ const startGroupCall = (channel, callType = 'voice') => {
+ if (activeVc) {
+ network.sendEphemeral({ type: 'vc-leave', serverTopicHex: activeVc.serverId, channel: activeVc.channelId });
+ setVcStates(prev => {
+ const serverVCS = prev[activeVc.serverId];
+ if (!serverVCS) return prev;
+ const channelVCS = serverVCS[activeVc.channelId];
+ if (!channelVCS) return prev;
+ const newChannelVCS = { ...channelVCS };
+ delete newChannelVCS[myKey];
+ return { ...prev, [activeVc.serverId]: { ...serverVCS,[activeVc.channelId]: newChannelVCS } };
+ });
+ setActiveVc(null);
+ }
+ network.sendEphemeral({ type: 'webrtc-group-ring', channel, callerName: profile.displayName, callType });
+ setActiveGroupCall({ channel, callType });
+ setShowChatInCall(false);
+ };
+
+ const handleJoinVC = (channelId) => {
+ if (activeCall) endCall();
+ if (activeGroupCall) setActiveGroupCall(null);
+ if (activeVc) {
+ network.sendEphemeral({ type: 'vc-leave', serverTopicHex: activeVc.serverId, channel: activeVc.channelId });
+ setVcStates(prev => {
+ const serverVCS = prev[activeVc.serverId];
+ if (!serverVCS) return prev;
+ const channelVCS = serverVCS[activeVc.channelId];
+ if (!channelVCS) return prev;
+ const newChannelVCS = { ...channelVCS };
+ delete newChannelVCS[myKey];
+ return { ...prev, [activeVc.serverId]: { ...serverVCS,[activeVc.channelId]: newChannelVCS } };
+ });
+ }
+ setActiveVc({ serverId: activeView, channelId });
+ setShowChatInCall(false);
+ };
+
+ const acceptCall = () => {
+ if (activeVc) {
+ network.sendEphemeral({ type: 'vc-leave', serverTopicHex: activeVc.serverId, channel: activeVc.channelId });
+ setVcStates(prev => {
+ const serverVCS = prev[activeVc.serverId];
+ if (!serverVCS) return prev;
+ const channelVCS = serverVCS[activeVc.channelId];
+ if (!channelVCS) return prev;
+ const newChannelVCS = { ...channelVCS };
+ delete newChannelVCS[myKey];
+ return { ...prev, [activeVc.serverId]: { ...serverVCS, [activeVc.channelId]: newChannelVCS } };
+ });
+ setActiveVc(null);
+ }
+ if (incomingCall.isGroup) {
+ setActiveGroupCall({ channel: incomingCall.channel, callType: incomingCall.callType });
+ setActiveView('dms');
+ setActiveDm(incomingCall.channel);
+ } else {
+ setActiveCall({ targetKey: incomingCall.targetKey, profile: incomingCall.profile, status: 'connecting', isCaller: false, callType: incomingCall.callType });
+ setActiveView('dms');
+ setActiveDm(incomingCall.targetKey);
+ network.sendWebRTCSignal(incomingCall.targetKey, { type: 'webrtc-accept' });
+ }
+ setShowChatInCall(false);
+ setIncomingCall(null);
+ };
+
+ const declineCall = () => {
+ if (!incomingCall.isGroup) {
+ network.sendWebRTCSignal(incomingCall.targetKey, { type: 'webrtc-decline' });
+ }
+ setIncomingCall(null);
+ };
+
+ const handleReturnToCall = () => {
+ if (activeCall) {
+ setActiveView('dms');
+ setActiveDm(activeCall.targetKey);
+ } else if (activeGroupCall) {
+ setActiveView('dms');
+ setActiveDm(activeGroupCall.channel);
+ } else if (activeVc) {
+ setActiveView(activeVc.serverId);
+ }
+ setShowChatInCall(false);
+ };
+
+ const unreadCounts = {};
+ messages.forEach(m => {
+ const channelId = m.recipient ? (m.sender === myKey ? m.recipient : m.sender) : m.channel;
+ if (m.sender !== myKey && m.timestamp > (lastRead[channelId] || 0)) {
+ unreadCounts[channelId] = (unreadCounts[channelId] || 0) + 1;
+ }
+ });
+
+ const isViewingCallDM = activeCall && activeView === 'dms' && activeDm === activeCall.targetKey;
+ const isViewingGroupCall = activeGroupCall && activeView === 'dms' && activeDm === activeGroupCall.channel;
+ const isViewingVC = activeVc && activeView === activeVc.serverId;
+ const showCallView = (isViewingCallDM || isViewingGroupCall || isViewingVC) && !showChatInCall;
+
+ const isGroupChat = activeView === 'dms' && servers.some(s => s.topicHex === activeDm && s.isGroupChat);
+ const inviteServerObj = servers.find(s => s.topicHex === inviteModalServer);
+
+ return (
+
+
setIsCreateServerOpen(true)}
+ onLeaveServer={(topicHex) => {
+ network.leaveServer(topicHex);
+ if (activeView === topicHex) setActiveView('dms');
+ }}
+ />
+
+ {activeView === 'dms' ? (
+ {
+ setActiveDm(ch);
+ setLastRead(prev => ({ ...prev,[ch]: Date.now() }));
+ }}
+ myKey={myKey}
+ profile={profile}
+ unreadCounts={unreadCounts}
+ onOpenSettings={() => setIsSettingsOpen(true)}
+ dms={dms}
+ servers={servers}
+ onlinePeers={onlinePeers}
+ typingUsers={typingUsers}
+ activeCall={activeCall || activeGroupCall || activeVc}
+ onReturnToCall={handleReturnToCall}
+ onOpenCreateGroup={() => setIsCreateGroupOpen(true)}
+ onLeaveGroup={(topicHex) => {
+ network.leaveServer(topicHex);
+ if (activeDm === topicHex) setActiveDm('friends');
+ }}
+ onDeleteGroup={(topicHex) => {
+ network.deleteServer(topicHex);
+ if (activeDm === topicHex) setActiveDm('friends');
+ }}
+ isNetworkOnline={isNetworkOnline}
+ />
+ ) : (
+ {
+ setActiveChannel(ch);
+ const netId = `${activeView}-${ch}`;
+ setLastRead(prev => ({ ...prev,[netId]: Date.now() }));
+ }}
+ myKey={myKey}
+ profile={profile}
+ unreadCounts={unreadCounts}
+ onOpenSettings={() => setIsSettingsOpen(true)}
+ activeView={activeView}
+ servers={servers}
+ serverMembers={serverMembers}
+ onlinePeers={onlinePeers}
+ knownUsers={knownUsers}
+ isSyncing={isSyncing}
+ onOpenInvite={() => setInviteModalServer(activeView)}
+ onOpenServerSettings={() => setSettingsModalServer(activeView)}
+ activeCall={activeCall || activeGroupCall || activeVc}
+ onReturnToCall={handleReturnToCall}
+ vcStates={vcStates}
+ activeVc={activeVc}
+ onJoinVC={handleJoinVC}
+ isNetworkOnline={isNetworkOnline}
+ />
+ )}
+
+ {/* Main Content Area */}
+
+
+ {/* Chat Area (Hidden if CallView is active) */}
+
+ {activeView === 'dms' && activeDm === 'friends' ? (
+
+ ) : (
+ setLastRead(prev => ({ ...prev,[networkId]: Date.now() }))}
+ dms={dms}
+ servers={servers}
+ onStartCall={(ch, type) => {
+ const isGC = servers.some(s => s.topicHex === ch && s.isGroupChat);
+ if (isGC) startGroupCall(ch, type);
+ else startCall(ch, type);
+ }}
+ activeCall={activeCall || (activeGroupCall ? { targetKey: activeGroupCall.channel } : null)}
+ onReturnToCall={() => setShowChatInCall(false)}
+ transfers={transfers}
+ onOpenInvite={(topicHex) => setInviteModalServer(topicHex)}
+ onToggleMembers={() => setShowMembersDrawer(!showMembersDrawer)}
+ />
+ )}
+
+
+ {/* 1-on-1 Call View */}
+ {activeCall && (
+
setShowChatInCall(true)}
+ onConnected={() => setActiveCall(prev => prev ? { ...prev, status: 'connected' } : null)}
+ />
+ )}
+
+ {/* Group Call View (Used for both DMs and Server VCs) */}
+ {(activeGroupCall || activeVc) && (
+ {
+ if (!activeVc) return;
+ setVcStates(prev => {
+ const serverVCS = prev[activeVc.serverId] || {};
+ const channelVCS = serverVCS[activeVc.channelId] || {};
+ return {
+ ...prev,
+ [activeVc.serverId]: {
+ ...serverVCS,
+ [activeVc.channelId]: {
+ ...channelVCS,
+ [myKey]: { muted, screenshare, timestamp: Date.now() }
+ }
+ }
+ };
+ });
+ }}
+ onClose={() => {
+ if (activeGroupCall) setActiveGroupCall(null);
+ if (activeVc) {
+ network.sendEphemeral({ type: 'vc-leave', serverTopicHex: activeVc.serverId, channel: activeVc.channelId });
+ setVcStates(prev => {
+ const serverVCS = prev[activeVc.serverId];
+ if (!serverVCS) return prev;
+ const channelVCS = serverVCS[activeVc.channelId];
+ if (!channelVCS) return prev;
+ const newChannelVCS = { ...channelVCS };
+ delete newChannelVCS[myKey];
+ return { ...prev, [activeVc.serverId]: { ...serverVCS,[activeVc.channelId]: newChannelVCS } };
+ });
+ setActiveVc(null);
+ }
+ }}
+ onToggleChat={() => setShowChatInCall(true)}
+ />
+ )}
+
+ {/* Members Drawer */}
+
+ setShowMembersDrawer(false)}
+ />
+
+
+
+
+ {isSettingsOpen && (
+ setIsSettingsOpen(false)}
+ onSave={handleSaveProfile}
+ onLogout={onLogout}
+ dms={dms}
+ servers={servers}
+ knownUsers={knownUsers}
+ updateState={updateState}
+ simulatedProgress={simulatedProgress}
+ triggerRestart={triggerRestart}
+ />
+ )}
+
+ {isCreateServerOpen && (
+ setIsCreateServerOpen(false)} onSave={handleCreateServer} />
+ )}
+
+ {isCreateGroupOpen && (
+ setIsCreateGroupOpen(false)}
+ onSave={handleCreateGroup}
+ dms={dms}
+ />
+ )}
+
+ {inviteModalServer && (
+ setInviteModalServer(null)}
+ serverTopicHex={inviteModalServer}
+ dms={dms}
+ serverMembers={serverMembers}
+ isGroupChat={inviteServerObj?.isGroupChat}
+ />
+ )}
+
+ {settingsModalServer && (
+ setSettingsModalServer(null)}
+ activeServerObj={servers.find(s => s.topicHex === settingsModalServer)}
+ onDeleteServer={() => {
+ network.deleteServer(settingsModalServer);
+ setSettingsModalServer(null);
+ }}
+ />
+ )}
+
+ {incomingCall && (
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/OnlineUsers.jsx b/Peercord Source/src/components/OnlineUsers.jsx
new file mode 100644
index 0000000..f951553
--- /dev/null
+++ b/Peercord Source/src/components/OnlineUsers.jsx
@@ -0,0 +1,111 @@
+import React from 'react';
+import { network, ADMIN_PUBLIC_KEY } from '../p2p/index.js';
+
+export default function OnlineUsers({ onlinePeers, knownUsers, dms, myKey, profile, activeView, servers, serverMembers, onClose }) {
+
+ const handleSendRequest = (e, peer) => {
+ e.stopPropagation();
+ network.sendDMRequest(peer.key, { displayName: peer.displayName, username: peer.username, avatar: peer.avatar });
+ };
+
+ const isCustomServer = activeView !== 'dms';
+ const serverObj = isCustomServer ? servers.find(s => s.topicHex === activeView) : null;
+ const isGroupChat = serverObj?.isGroupChat;
+
+ const currentMembers = isCustomServer ? new Set(serverMembers[activeView] ||[]) : null;
+
+ if (isCustomServer && serverObj) {
+ currentMembers.add(serverObj.owner);
+ currentMembers.add(myKey);
+ }
+
+ const me = { key: myKey, displayName: profile.displayName, username: profile.username, avatar: profile.avatar };
+ const allOnlinePeers = [me, ...onlinePeers];
+
+ const filteredOnlinePeers = isCustomServer ? allOnlinePeers.filter(p => currentMembers.has(p.key)) : allOnlinePeers;
+ const onlineKeys = new Set(filteredOnlinePeers.map(p => p.key));
+
+ const offlineUsers =[];
+ if (isCustomServer && currentMembers) {
+ currentMembers.forEach(key => {
+ if (!onlineKeys.has(key) && key !== myKey) {
+ const known = knownUsers.find(u => u.key === key);
+ if (known) offlineUsers.push(known);
+ else offlineUsers.push({ key, displayName: 'Unknown User', username: 'unknown', avatar: null });
+ }
+ });
+ } else {
+ offlineUsers.push(...knownUsers.filter(u => !onlineKeys.has(u.key) && u.key !== myKey));
+ }
+
+ const renderUser = (peer, isOnline) => {
+ const dmState = dms[peer.key]?.status;
+ let isPlatformAdmin = peer.key === ADMIN_PUBLIC_KEY;
+ let isServerOwner = isCustomServer && !isGroupChat && serverObj?.owner === peer.key;
+ let isGroupCreator = isGroupChat && serverObj?.owner === peer.key;
+
+ return (
+
+
+
+
+ {peer.avatar ? (
+

+ ) : (
+ peer.displayName.substring(0, 2).toUpperCase()
+ )}
+
+
+
+
+
+ {peer.displayName} {peer.key === myKey && (You)}
+ {isPlatformAdmin && 👑}
+ {isServerOwner && 👑}
+ {isGroupCreator && 👑}
+
+ @{peer.username}
+
+
+
+ {!dmState && peer.key !== myKey && (
+
+ )}
+
+ );
+ };
+
+ return (
+
+
+
+ {isGroupChat ? 'Members' : 'Online'} — {filteredOnlinePeers.length}
+
+
+
+
+
+ {filteredOnlinePeers.map(peer => renderUser(peer, true))}
+
+
+ {offlineUsers.length > 0 && (
+ <>
+
+ Offline — {offlineUsers.length}
+
+
+ {offlineUsers.map(peer => renderUser(peer, false))}
+
+ >
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/ProfileSettingsModal.jsx b/Peercord Source/src/components/ProfileSettingsModal.jsx
new file mode 100644
index 0000000..597ad92
--- /dev/null
+++ b/Peercord Source/src/components/ProfileSettingsModal.jsx
@@ -0,0 +1,586 @@
+import React, { useState, useRef, useEffect } from 'react';
+import { network, ADMIN_PUBLIC_KEY } from '../p2p/index.js';
+
+function StorageSettings({ dms, servers, knownUsers }) {
+ const[stats, setStats] = useState(null);
+
+ const fetchStats = () => {
+ network.getStorageStats()
+ .then(setStats)
+ .catch(err => {
+ console.error("Failed to load storage stats:", err);
+ setStats({ total: 0, dms: {}, servers: {}, files:[] });
+ });
+ };
+
+ useEffect(() => {
+ fetchStats();
+ },[]);
+
+ const handlePrune = async (msgId) => {
+ try {
+ await network.pruneFile(msgId);
+ } catch (err) {
+ console.error("Failed to prune file:", err);
+ } finally {
+ fetchStats();
+ }
+ };
+
+ if (!stats) return Loading storage stats...
;
+
+ const formatBytes = (bytes) => {
+ if (!+bytes) return '0 Bytes';
+ const k = 1024;
+ const sizes =['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
+ };
+
+ return (
+
+
Storage Management
+
+
+
Total Space Used by Media
+
{formatBytes(stats.total)}
+
+
+
+
Whispers
+
+ {Object.entries(stats.dms).map(([key, size]) => {
+ const name = dms[key]?.profile?.displayName || knownUsers.find(u => u.key === key)?.displayName || 'Unknown';
+ return (
+
+ {name}
+ {formatBytes(size)}
+
+ );
+ })}
+ {Object.keys(stats.dms).length === 0 &&
No media in Whispers
}
+
+
+
+
+
Hubs
+
+ {Object.entries(stats.servers).map(([topicHex, data]) => {
+ const server = servers.find(s => s.topicHex === topicHex);
+ const name = server ? server.name : 'Unknown Hub';
+ return (
+
+
+ {name}
+ {formatBytes(data.total)}
+
+ {Object.entries(data.channels).map(([ch, size]) => (
+
+ #{ch}
+ {formatBytes(size)}
+
+ ))}
+
+ );
+ })}
+ {Object.keys(stats.servers).length === 0 &&
No media in Hubs
}
+
+
+
+
+
+
+
Large Files
+
+ {stats.files.slice(0, 50).map(file => (
+
+
+ {file.name}
+ {new Date(file.timestamp).toLocaleString()}
+
+
+ {formatBytes(file.size)}
+
+
+
+ ))}
+ {stats.files.length === 0 &&
No large files found.
}
+
+
+
+ );
+}
+
+export default function ProfileSettingsModal({ profile, myKey, onClose, onSave, onLogout, dms, servers, knownUsers, updateState, triggerRestart }) {
+ const[activeTab, setActiveTab] = useState('account');
+ const[tempName, setTempName] = useState(profile.displayName);
+ const[tempAvatar, setTempAvatar] = useState(profile.avatar);
+ const[showSeed, setShowSeed] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const isLegacyAccount = !profile.username || profile.username === 'unknown';
+ const[tempUsername, setTempUsername] = useState(isLegacyAccount ? '' : profile.username);
+
+ const[audioInputs, setAudioInputs] = useState([]);
+ const[audioOutputs, setAudioOutputs] = useState([]);
+ const[videoInputs, setVideoInputs] = useState([]);
+ const[selectedInput, setSelectedInput] = useState(localStorage.getItem('pear_audio_input') || 'default');
+ const [selectedOutput, setSelectedOutput] = useState(localStorage.getItem('pear_audio_output') || 'default');
+ const [selectedVideoInput, setSelectedVideoInput] = useState(localStorage.getItem('pear_video_input') || 'default');
+
+ const [autoRestart, setAutoRestart] = useState(localStorage.getItem('pear_auto_restart') !== 'false');
+ const [liveDecryption, setLiveDecryption] = useState(localStorage.getItem('pear_live_decryption') === 'true');
+
+ const defaultTheme = {
+ base: '#000000',
+ surface: '#0a0a0a',
+ panel: '#121212',
+ accent: '#5865F2',
+ text: '#f3f4f6',
+ muted: '#9ca3af'
+ };
+ const [theme, setTheme] = useState(() => JSON.parse(localStorage.getItem('peercord_theme')) || defaultTheme);
+
+ useEffect(() => {
+ if (activeTab === 'voice') {
+ navigator.mediaDevices.enumerateDevices().then(devices => {
+ setAudioInputs(devices.filter(d => d.kind === 'audioinput'));
+ setAudioOutputs(devices.filter(d => d.kind === 'audiooutput'));
+ setVideoInputs(devices.filter(d => d.kind === 'videoinput'));
+ }).catch(err => console.error("Failed to enumerate devices:", err));
+ }
+ },[activeTab]);
+
+ const handleInputSelect = (id) => {
+ setSelectedInput(id);
+ localStorage.setItem('pear_audio_input', id);
+ };
+
+ const handleOutputSelect = (id) => {
+ setSelectedOutput(id);
+ localStorage.setItem('pear_audio_output', id);
+ };
+
+ const handleVideoInputSelect = (id) => {
+ setSelectedVideoInput(id);
+ localStorage.setItem('pear_video_input', id);
+ };
+
+ const handleAutoRestartToggle = (e) => {
+ setAutoRestart(e.target.checked);
+ localStorage.setItem('pear_auto_restart', e.target.checked);
+ };
+
+ const handleThemeChange = (key, val) => {
+ const newTheme = { ...theme, [key]: val };
+ setTheme(newTheme);
+ document.documentElement.style.setProperty(`--color-${key}`, val);
+ localStorage.setItem('peercord_theme', JSON.stringify(newTheme));
+ };
+
+ const resetTheme = () => {
+ setTheme(defaultTheme);
+ Object.entries(defaultTheme).forEach(([key, val]) => {
+ document.documentElement.style.setProperty(`--color-${key}`, val);
+ });
+ localStorage.setItem('peercord_theme', JSON.stringify(defaultTheme));
+ };
+
+ const handleImageUpload = (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ const img = new Image();
+ img.onload = () => {
+ const canvas = document.createElement('canvas');
+ const MAX_SIZE = 128;
+ let width = img.width;
+ let height = img.height;
+
+ if (width > height) {
+ if (width > MAX_SIZE) {
+ height *= MAX_SIZE / width;
+ width = MAX_SIZE;
+ }
+ } else {
+ if (height > MAX_SIZE) {
+ width *= MAX_SIZE / height;
+ height = MAX_SIZE;
+ }
+ }
+
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(img, 0, 0, width, height);
+
+ const mimeType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
+ const dataUrl = canvas.toDataURL(mimeType, 0.8);
+
+ setTempAvatar(dataUrl);
+ };
+ img.src = event.target.result;
+ };
+ reader.readAsDataURL(file);
+ };
+
+ const handleSave = () => {
+ if (tempName.trim() === '') return;
+
+ let finalUsername = profile.username;
+ if (isLegacyAccount) {
+ finalUsername = tempUsername.trim().toLowerCase().replace(/[^a-z0-9_.]/g, '');
+ if (!finalUsername) return alert("Invalid username. Use only letters, numbers, underscores, and periods.");
+ }
+
+ onSave(tempName.trim(), tempAvatar, finalUsername);
+ };
+
+ const copySeed = () => {
+ if (profile.seedHex) navigator.clipboard.writeText(profile.seedHex);
+ };
+
+ 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();
+ }
+ };
+
+ return (
+
+ {/* Sidebar */}
+
+
+
User Settings
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Content Area */}
+
+
+
+ {activeTab === 'account' && (
+
+
My Account
+
+
+
+
+
Account Seed (Private Key)
+
+ This 64-character seed is the only way to log back into your account if you switch devices. Do not share it with anyone!
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {activeTab === 'appearance' && (
+
+
Appearance
+
+
+
Theme Colors
+
+ {Object.entries(theme).map(([key, val]) => (
+
+
{key}
+
+ {val}
+ handleThemeChange(key, e.target.value)}
+ className="w-8 h-8 rounded cursor-pointer bg-transparent border-none p-0"
+ />
+
+
+ ))}
+
+
+
+
+
+
+ )}
+
+ {activeTab === 'voice' && (
+
+
Voice & Video Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {activeTab === 'storage' && (
+
+ )}
+
+ {activeTab === 'settings' && (
+
+
App Settings
+
+
+
Direct Messages
+
+ {
+ setLiveDecryption(e.target.checked);
+ localStorage.setItem('pear_live_decryption', e.target.checked);
+ }}
+ className="w-5 h-5 accent-accent cursor-pointer"
+ />
+ Enable Live Decryption Animation
+
+
+ Visually animates the decryption of incoming end-to-end encrypted messages in real-time.
+
+
+
+
+
Update Status
+ {updateState === 'downloading' ? (
+
+
Downloading new version...
+
+
Please wait...
+
+ ) : updateState === 'available' || updateState === 'countdown' ? (
+
+ Update Ready to Install
+
+
+ ) : updateState === 'gossip_available' || updateState === 'gossip_countdown' ? (
+
+ New Update Broadcasted!
+ Restart the app to connect to the new seeder and begin downloading.
+
+
+ ) : (
+
App is up to date.
+ )}
+
+
+
+
Updates
+
+
+ Automatically restart to apply updates
+
+
+ If disabled, you will be prompted to restart manually. (Auto-restart is always paused during calls or file transfers).
+
+
+
+
+
Danger Zone
+
+ This will permanently delete all your local data, including your cryptographic identity, messages, contacts, and hubs you've joined or created. This action cannot be undone and you will lose access to everything.
+
+
+
+
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/ScreenShareModal.jsx b/Peercord Source/src/components/ScreenShareModal.jsx
new file mode 100644
index 0000000..b91f5d4
--- /dev/null
+++ b/Peercord Source/src/components/ScreenShareModal.jsx
@@ -0,0 +1,185 @@
+import React, { useState, useEffect } from 'react';
+
+export default function ScreenShareModal({ onClose, onStart }) {
+ const[resolution, setResolution] = useState('1080');
+ const [fps, setFps] = useState('60');
+
+ const[activeTab, setActiveTab] = useState('screens');
+ const[sources, setSources] = useState({ screens:[], windows: [] });
+ const [selectedSource, setSelectedSource] = useState(null);
+ const[useNativePicker, setUseNativePicker] = useState(false);
+
+ const resolutions =[
+ { value: '1080', label: '1080p', width: 1920, height: 1080 },
+ { value: '720', label: '720p', width: 1280, height: 720 },
+ { value: '480', label: '480p', width: 854, height: 480 },
+ { value: '360', label: '360p', width: 640, height: 360 },
+ { value: '240', label: '240p', width: 426, height: 240 },
+ { value: '144', label: '144p', width: 256, height: 144 }
+ ];
+
+ const framerates =[
+ { value: '60', label: '60 FPS (Smoothest)' },
+ { value: '30', label: '30 FPS (Standard)' },
+ { value: '15', label: '15 FPS (Low Bandwidth)' }
+ ];
+
+ useEffect(() => {
+ const fetchSources = async () => {
+ try {
+ if (typeof window !== 'undefined' && window.require) {
+ const { ipcRenderer } = window.require('electron');
+ const allSources = await ipcRenderer.invoke('get-desktop-sources');
+
+ const formattedSources = allSources.map(s => ({
+ id: s.id,
+ name: s.name,
+ thumbnail: { toDataURL: () => s.thumbnailDataURL }
+ }));
+
+ const screens = formattedSources.filter(s => s.id.startsWith('screen'));
+ const windows = formattedSources.filter(s => s.id.startsWith('window'));
+
+ setSources({ screens, windows });
+ if (screens.length > 0) setSelectedSource(screens[0].id);
+ return;
+ }
+ } catch (e) {
+ console.warn("desktopSources not available, falling back to native picker", e);
+ }
+ setUseNativePicker(true);
+ };
+
+ fetchSources();
+ },[]);
+
+ const handleStart = () => {
+ const selectedRes = resolutions.find(r => r.value === resolution);
+ const sourceId = useNativePicker ? 'native' : selectedSource;
+ if (!sourceId) return;
+ onStart(sourceId, selectedRes, parseInt(fps));
+ };
+
+ return (
+
+
+
+
+
Screen Share Settings
+
+ Configure your stream quality and select what you want to share.
+
+
+
+
+
+ {/* Left Side: Source Selection */}
+
+ {!useNativePicker ? (
+ <>
+
+
+
+
+
+
+
+ {sources[activeTab].map(source => (
+
setSelectedSource(source.id)}
+ className={`flex flex-col gap-2 p-2 rounded cursor-pointer border-2 transition-colors ${selectedSource === source.id ? 'border-accent bg-accent/10' : 'border-transparent hover:bg-surface'}`}
+ >
+
})
+
{source.name}
+
+ ))}
+ {sources[activeTab].length === 0 && (
+
+ No {activeTab} found.
+
+ )}
+
+
+ >
+ ) : (
+
+
+
+
+
Native Picker Required
+
+ Your environment requires using the system's native screen picker. Click "Start Sharing" to open it.
+
+
+ )}
+
+
+ {/* Right Side: Quality Settings */}
+
+
+
+
+
+ If your connection is slow, the system will automatically downgrade resolution to maintain framerate.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/ServerInviteCard.jsx b/Peercord Source/src/components/ServerInviteCard.jsx
new file mode 100644
index 0000000..c0db0d6
--- /dev/null
+++ b/Peercord Source/src/components/ServerInviteCard.jsx
@@ -0,0 +1,41 @@
+import React, { useState } from 'react';
+import { network } from '../p2p/index.js';
+
+export default function ServerInviteCard({ invite, joinedServers }) {
+ const { serverName, serverIcon, serverTopicHex, inviterName, serverOwner, allowAnyoneToInvite, isGroupChat, channels } = invite;
+ const[isJoined, setIsJoined] = useState(joinedServers.some(s => s.topicHex === serverTopicHex));
+
+ const handleJoin = () => {
+ if (isJoined) return;
+ network.joinServer(serverTopicHex, serverName, serverIcon, serverOwner, allowAnyoneToInvite, isGroupChat, channels);
+ setIsJoined(true);
+ };
+
+ return (
+
+
+ You've been invited to join a {isGroupChat ? 'Group Whisper' : 'Hub'}
+
+
+
+ {serverIcon ? (
+

+ ) : (
+ serverName.substring(0, 2).toUpperCase()
+ )}
+
+
+ {serverName}
+ Invited by {inviterName}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/ServerSettingsModal.jsx b/Peercord Source/src/components/ServerSettingsModal.jsx
new file mode 100644
index 0000000..02c8f3a
--- /dev/null
+++ b/Peercord Source/src/components/ServerSettingsModal.jsx
@@ -0,0 +1,124 @@
+import React, { useState, useRef } from 'react';
+import { network } from '../p2p/index.js';
+
+export default function ServerSettingsModal({ onClose, activeServerObj, onDeleteServer }) {
+ const [serverName, setServerName] = useState(activeServerObj.name || '');
+ const [serverIcon, setServerIcon] = useState(activeServerObj.icon || null);
+ const[allowAnyone, setAllowAnyone] = useState(activeServerObj.allowAnyoneToInvite);
+ const fileInputRef = useRef(null);
+
+ const handleImageUpload = (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ const img = new Image();
+ img.onload = () => {
+ const canvas = document.createElement('canvas');
+ const MAX_SIZE = 128;
+ let width = img.width;
+ let height = img.height;
+
+ if (width > height) {
+ if (width > MAX_SIZE) { height *= MAX_SIZE / width; width = MAX_SIZE; }
+ } else {
+ if (height > MAX_SIZE) { width *= MAX_SIZE / height; height = MAX_SIZE; }
+ }
+
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(img, 0, 0, width, height);
+
+ const dataUrl = canvas.toDataURL(file.type === 'image/png' ? 'image/png' : 'image/jpeg', 0.8);
+ setServerIcon(dataUrl);
+ };
+ img.src = event.target.result;
+ };
+ reader.readAsDataURL(file);
+ };
+
+ const handleSave = () => {
+ if (serverName.trim() === '') return;
+ network.updateServerSettings(activeServerObj.topicHex, serverName.trim(), serverIcon, allowAnyone);
+ onClose();
+ };
+
+ const handleDelete = () => {
+ if (window.confirm("Are you sure you want to completely delete this hub? All members will be removed and message history will be permanently wiped for everyone. This cannot be undone.")) {
+ onDeleteServer();
+ }
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
Hub Settings
+
+
+
fileInputRef.current?.click()}
+ >
+ {serverIcon ? (
+

+ ) : (
+
+ )}
+
+ Upload
+
+
+
+
+
+
+
setServerName(e.target.value)}
+ className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-2 focus:ring-accent mb-4"
+ placeholder="e.g. My Cool Club"
+ maxLength={32}
+ />
+
+
+
+ setAllowAnyone(e.target.checked)}
+ className="w-5 h-5 accent-accent cursor-pointer"
+ />
+ Anyone can invite people to this hub
+
+
If unchecked, only you (the Admin) can send invites.
+
+
+
Danger Zone
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/SetupScreen.jsx b/Peercord Source/src/components/SetupScreen.jsx
new file mode 100644
index 0000000..97ecccf
--- /dev/null
+++ b/Peercord Source/src/components/SetupScreen.jsx
@@ -0,0 +1,212 @@
+import React, { useState, useEffect } from 'react';
+import { generateIdentitySeed, network } from '../p2p/index.js';
+import logo from '../../assets/icon.png';
+
+export default function SetupScreen({ setProfile }) {
+ const [view, setView] = useState('saved');
+ const [savedAccounts, setSavedAccounts] = useState([]);
+
+ const [displayName, setDisplayName] = useState('');
+ const[username, setUsername] = useState('');
+ const [seedHex, setSeedHex] = useState('');
+ const [isChecking, setIsChecking] = useState(false);
+
+ useEffect(() => {
+ const accounts = JSON.parse(localStorage.getItem('pear_saved_accounts') || '[]');
+ setSavedAccounts(accounts);
+ if (accounts.length === 0) setView('signup');
+ },[]);
+
+ const saveAccountToStorage = (profile) => {
+ const accounts = JSON.parse(localStorage.getItem('pear_saved_accounts') || '[]');
+ const existingIndex = accounts.findIndex(a => a.seedHex === profile.seedHex);
+ if (existingIndex >= 0) {
+ accounts[existingIndex] = profile;
+ } else {
+ accounts.push(profile);
+ }
+ localStorage.setItem('pear_saved_accounts', JSON.stringify(accounts));
+ };
+
+ const handleSignup = async (e) => {
+ e.preventDefault();
+ if (!displayName.trim() || !username.trim()) return;
+
+ const cleanUsername = username.trim().toLowerCase().replace(/[^a-z0-9_.]/g, '');
+ if (!cleanUsername) return alert("Invalid username. Use only letters, numbers, underscores, and periods.");
+
+ setIsChecking(true);
+ try {
+ const isAvailable = await network.checkUsernameAvailable(cleanUsername);
+ if (!isAvailable) {
+ alert("This username is currently in use by an online peer. Please choose another one.");
+ setIsChecking(false);
+ return;
+ }
+ } catch (err) {
+ console.error("Username check failed:", err);
+ }
+ setIsChecking(false);
+
+ const newSeedHex = generateIdentitySeed();
+ const profile = { displayName: displayName.trim(), username: cleanUsername, seedHex: newSeedHex, avatar: null };
+
+ saveAccountToStorage(profile);
+ localStorage.setItem('pear_discord_identity', JSON.stringify(profile));
+ setProfile(profile);
+ };
+
+ const handleLogin = (e) => {
+ e.preventDefault();
+ if (!seedHex.trim() || !displayName.trim() || !username.trim()) return;
+
+ const cleanUsername = username.trim().toLowerCase().replace(/[^a-z0-9_.]/g, '');
+ const profile = { displayName: displayName.trim(), username: cleanUsername, seedHex: seedHex.trim(), avatar: null };
+
+ saveAccountToStorage(profile);
+ localStorage.setItem('pear_discord_identity', JSON.stringify(profile));
+ setProfile(profile);
+ };
+
+ const handleSavedLogin = (profile) => {
+ localStorage.setItem('pear_discord_identity', JSON.stringify(profile));
+ setProfile(profile);
+ };
+
+ return (
+
+
+

+
+ {view === 'saved' && (
+
+
Welcome Back
+
Select an account to log in.
+
+
+ {savedAccounts.map((acc, i) => (
+
handleSavedLogin(acc)}
+ className="flex items-center gap-3 p-3 bg-panel hover:bg-base rounded cursor-pointer transition-colors border border-surface"
+ >
+
+ {acc.avatar ?

: acc.displayName.substring(0, 2).toUpperCase()}
+
+
+ {acc.displayName}
+ @{acc.username}
+
+
+ ))}
+
+
+
+
+
+
+
+ )}
+
+ {view === 'signup' && (
+
+ )}
+
+ {view === 'login' && (
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/Sidebar.jsx b/Peercord Source/src/components/Sidebar.jsx
new file mode 100644
index 0000000..1ff26eb
--- /dev/null
+++ b/Peercord Source/src/components/Sidebar.jsx
@@ -0,0 +1,115 @@
+import React, { useState, useEffect } from 'react';
+import logo from '../../assets/iconWhite.png';
+
+export default function Sidebar({ activeView, setActiveView, servers, myKey, onOpenCreateServer, onLeaveServer }) {
+ const[contextMenu, setContextMenu] = useState(null);
+
+ useEffect(() => {
+ const handleClick = () => setContextMenu(null);
+ if (contextMenu) document.addEventListener('click', handleClick);
+ return () => document.removeEventListener('click', handleClick);
+ },[contextMenu]);
+
+ const publicServers = servers.filter(s => s.isGroupChat !== true);
+
+ const NavItem = ({ id, icon, name, isImage, imageClass, onClick, onContextMenu }) => {
+ const isActive = activeView === id;
+ return (
+
+
+
+ {isImage ? (
+

+ ) : (
+ icon
+ )}
+
+
+ {/* Discord-style Tooltip */}
+
+
+ );
+ };
+
+ return (
+
+
+ {contextMenu && (
+
setContextMenu(null)} onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}>
+
e.stopPropagation()}
+ >
+
+
+
+ )}
+
+
setActiveView('dms')}
+ />
+
+
+
+ {publicServers.map(server => (
+ setActiveView(server.topicHex)}
+ onContextMenu={(e) => {
+ e.preventDefault();
+ if (server.owner === myKey) return;
+ setContextMenu({ x: e.pageX, y: e.pageY, topicHex: server.topicHex });
+ }}
+ />
+ ))}
+
+
+
+
+
+
+ {/* Discord-style Tooltip */}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/components/TitleBar.jsx b/Peercord Source/src/components/TitleBar.jsx
new file mode 100644
index 0000000..da7f2d0
--- /dev/null
+++ b/Peercord Source/src/components/TitleBar.jsx
@@ -0,0 +1,111 @@
+import React, { useState, useEffect } from 'react';
+import logo from '../../assets/icon.png';
+
+export default function TitleBar() {
+ const[isMaximized, setIsMaximized] = useState(false);
+
+ useEffect(() => {
+ let cleanupIpc = null;
+
+ // Reliable IPC listener for Electron (.exe build)
+ if (typeof window !== 'undefined' && window.require) {
+ try {
+ const { ipcRenderer } = window.require('electron');
+ const handleWindowState = (e, isMax) => setIsMaximized(isMax);
+ ipcRenderer.on('window-state-changed', handleWindowState);
+ cleanupIpc = () => ipcRenderer.removeListener('window-state-changed', handleWindowState);
+ } catch (e) {}
+ }
+
+ // Multi-monitor resilient heuristic
+ const handleResize = () => {
+ const isMax = (window.outerHeight >= window.screen.availHeight * 0.85) ||
+ (window.outerWidth >= window.screen.availWidth * 0.85);
+
+ setIsMaximized(isMax);
+ };
+
+ window.addEventListener('resize', handleResize);
+ handleResize();
+
+ return () => {
+ window.removeEventListener('resize', handleResize);
+ if (cleanupIpc) cleanupIpc();
+ };
+ },[]);
+
+ const performAction = async (action) => {
+ try {
+ if (typeof window !== 'undefined' && window.require) {
+ const { ipcRenderer } = window.require('electron');
+ ipcRenderer.send('window-action', action);
+ }
+ } catch (e) {
+ console.error("Failed to perform window action:", e);
+ }
+ };
+
+ const handleMinimize = () => performAction('minimize');
+
+ const handleMaximize = async () => {
+ if (isMaximized) {
+ await performAction('restore');
+ setIsMaximized(false);
+ } else {
+ await performAction('maximize');
+ setIsMaximized(true);
+ }
+ };
+
+ const handleClose = () => performAction('close');
+
+ return (
+
+
+

+ Peercord
+
+ {window.APP_VERSION && (
+
+
+
v{window.APP_VERSION}
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Peercord Source/src/index.css b/Peercord Source/src/index.css
new file mode 100644
index 0000000..2877695
--- /dev/null
+++ b/Peercord Source/src/index.css
@@ -0,0 +1,88 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ /* Default OLED Black & Blue Theme */
+ --color-base: #000000;
+ --color-surface: #0a0a0a;
+ --color-panel: #121212;
+ --color-accent: #5865F2;
+ --color-text: #f3f4f6;
+ --color-muted: #9ca3af;
+}
+
+html, body, #root {
+ height: 100%;
+ width: 100%;
+ background-color: var(--color-base);
+ color: var(--color-text);
+}
+
+.titlebar {
+ -webkit-app-region: drag;
+ user-select: none;
+}
+
+.titlebar-button {
+ -webkit-app-region: no-drag;
+}
+
+/* Global Custom Scrollbar Styling */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-surface) transparent;
+}
+
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb {
+ background-color: var(--color-surface);
+ border-radius: 4px;
+ border: 1px solid var(--color-base);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background-color: var(--color-muted);
+}
+
+.hide-scrollbar::-webkit-scrollbar {
+ display: none;
+}
+.hide-scrollbar {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+@keyframes typing-pulse {
+ 0%, 60%, 100% {
+ transform: scale(0.7) translateY(0);
+ background-color: var(--color-muted);
+ }
+ 30% {
+ transform: scale(1.2) translateY(-2px);
+ background-color: var(--color-text);
+ }
+}
+
+.typing-dot {
+ animation: typing-pulse 1.2s infinite ease-in-out;
+}
+
+/* Indeterminate Progress Bar Animation */
+@keyframes indeterminate {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(200%); }
+}
+
+.animate-indeterminate {
+ animation: indeterminate 1.5s infinite linear;
+}
\ No newline at end of file
diff --git a/Peercord Source/src/main.jsx b/Peercord Source/src/main.jsx
new file mode 100644
index 0000000..0291fe5
--- /dev/null
+++ b/Peercord Source/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App.jsx'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
\ No newline at end of file
diff --git a/Peercord Source/src/p2p/handlers.js b/Peercord Source/src/p2p/handlers.js
new file mode 100644
index 0000000..6141259
--- /dev/null
+++ b/Peercord Source/src/p2p/handlers.js
@@ -0,0 +1,112 @@
+const b4a = window.require('b4a');
+
+export async function handleData(network, peerKey, data, conn) {
+ try {
+ const parsed = JSON.parse(b4a.toString(data));
+
+ switch (parsed.type) {
+ case 'identity':
+ await handleIdentity(network, peerKey, parsed);
+ break;
+ case 'whois':
+ handleWhois(network, parsed, conn);
+ break;
+ case 'whois_reply':
+ handleWhoisReply(network, parsed);
+ break;
+ case 'ephemeral':
+ handleEphemeral(network, peerKey, parsed);
+ break;
+ default:
+ // Could be a standard message core, which is handled by replication, not this handler.
+ }
+ } catch (err) {
+ // Likely binary data from core replication, ignore.
+ }
+}
+
+async function handleIdentity(network, peerKey, parsed) {
+ const peerInfo = network.peers.get(peerKey);
+ if (!peerInfo) return;
+
+ peerInfo.displayName = parsed.displayName;
+ peerInfo.username = parsed.username;
+ peerInfo.avatar = parsed.avatar;
+ peerInfo.coreKey = parsed.coreKey;
+
+ const profileObj = { displayName: parsed.displayName, username: parsed.username, avatar: parsed.avatar };
+ network.knownProfiles.set(peerKey, profileObj);
+ if (network.profilesDb) await network.profilesDb.put(peerKey, profileObj);
+ if (network.coresDb && parsed.coreKey) await network.coresDb.put(peerKey, parsed.coreKey);
+
+ network._emitKnownProfiles();
+
+ if (parsed.username) {
+ const uname = parsed.username.toLowerCase();
+ network.userDirectory.set(uname, { pubKey: peerKey, profile: parsed });
+ network.dirDb.put(uname, { pubKey: peerKey, profile: parsed });
+ network._checkPendingRequests(uname, peerKey, parsed);
+ }
+
+ if (network.dms[peerKey]) {
+ network.dms[peerKey].profile = profileObj;
+ await network.db.put('dm:' + peerKey, network.dms[peerKey]);
+ if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
+ }
+
+ if (network.onPeerUpdate) network.onPeerUpdate(network.getPeerList());
+ network._emitMessages();
+
+ await network.trackPeerCore(parsed.coreKey);
+}
+
+function handleWhois(network, parsed, conn) {
+ const uname = parsed.username;
+ if (network.userDirectory.has(uname)) {
+ const cached = network.userDirectory.get(uname);
+ const reply = b4a.from(JSON.stringify({ type: 'whois_reply', queryId: parsed.queryId, username: uname, pubKey: cached.pubKey, profile: cached.profile }));
+ conn.write(reply);
+ }
+}
+
+function handleWhoisReply(network, parsed) {
+ const cb = network.pendingWhois.get(parsed.queryId);
+ if (cb) cb({ pubKey: parsed.pubKey, profile: parsed.profile });
+
+ network.userDirectory.set(parsed.username, { pubKey: parsed.pubKey, profile: parsed.profile });
+ network.dirDb.put(parsed.username, { pubKey: parsed.pubKey, profile: parsed.profile });
+ network._checkPendingRequests(parsed.username, parsed.pubKey, parsed.profile);
+}
+
+function handleEphemeral(network, peerKey, parsed) {
+ const { payload } = parsed;
+ if (!payload) return;
+
+ if (payload.type === 'offline') {
+ const peerInfo = network.peers.get(peerKey);
+ if (peerInfo) {
+ network.peers.delete(peerKey);
+ try { peerInfo.conn.destroy(); } catch (e) {}
+ if (network.onPeerUpdate) network.onPeerUpdate(network.getPeerList());
+ }
+ }
+
+ if (payload.type.startsWith('webrtc-') || payload.type === 'voice_activity' || payload.type.startsWith('vc-')) {
+ for (const fn of network.webrtcListeners) fn(peerKey, payload);
+ }
+
+ if (payload.type === 'transfer_progress') {
+ const current = network.transfers[payload.id] || { progress: 0, speed: 0 };
+ if (payload.progress >= current.progress || payload.progress === 1) {
+ network.transfers[payload.id] = {
+ ...current,
+ progress: payload.progress,
+ speed: payload.speed,
+ state: payload.progress >= 1 ? 'completed' : 'uploading'
+ };
+ if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
+ }
+ }
+
+ if (network.onEphemeral) network.onEphemeral(peerKey, payload);
+}
\ No newline at end of file
diff --git a/Peercord Source/src/p2p/index.js b/Peercord Source/src/p2p/index.js
new file mode 100644
index 0000000..2607a92
--- /dev/null
+++ b/Peercord Source/src/p2p/index.js
@@ -0,0 +1,618 @@
+const b4a = window.require('b4a');
+import { generateUUID, Hyperswarm, Corestore, Hyperbee, sodium, fs, os, path, http } from './utils.js';
+import * as Identity from './modules/identity.js';
+import { handleData } from './handlers.js';
+import { getAllMessages, processMessage, sendDMRequest, sendMessage, sendDM, sendEditMessage, sendDeleteMessage, acceptDMRequest, sendEphemeral, sendReadReceipt, sendDeliveredReceipt, sendOffline, sendTyping, _appendSignedMessage, _appendEncryptedMessage } from './modules/messaging.js';
+import { createServer, joinServer, deleteServer, leaveServer, sendServerInvite, updateServerSettings, sendGroupChatAdd } from './modules/servers.js';
+import { searchUser, queueFriendRequest, trackPeerCore } from './modules/discovery.js';
+import { sendFile, sendDMFile, downloadFile } from './modules/files.js';
+import { addWebRTCListener, removeWebRTCListener, sendWebRTCSignal } from './modules/webrtc.js';
+
+class P2PNetwork {
+ constructor() {
+ this.swarm = null;
+ this.store = null;
+ this.localCore = null;
+ this.db = null;
+ this.serverDb = null;
+ this.dirDb = null;
+ this.pendingRequestsDb = null;
+ this.localFilesDb = null;
+ this.coresDb = null;
+ this.profilesDb = null;
+
+ this.coreKey = null;
+ this.myKey = null;
+ this.secretKey = null;
+ this.displayName = '';
+ this.username = '';
+ this.avatar = null;
+ this.storagePath = null;
+
+ this.peers = new Map();
+ this.peerCores = new Map();
+ this.knownProfiles = new Map();
+ this.userDirectory = new Map();
+ this.pendingWhois = new Map();
+ this.pendingFriendRequests = new Set();
+
+ this.messages = new Map();
+ this.deletedMessages = new Set();
+ this.dms = {};
+ this.servers =[];
+ this.serverMembers = {};
+ this.joinedTopics = new Set();
+ this.syncTimeout = null;
+ this._msgTimeout = null;
+
+ this.transfers = {};
+ this.webrtcListeners = new Set();
+
+ // Distributed Systems Ordering
+ this.logicalClock = 0;
+ this.timeOffset = 0;
+
+ // App State Tracking (Used to prevent auto-restarts during critical operations)
+ this.activeCalls = 0;
+
+ this.onInit = null;
+ this.onPeerUpdate = null;
+ this.onMessage = null;
+ this.onEphemeral = null;
+ this.onDMsUpdate = null;
+ this.onKnownProfilesUpdate = null;
+ this.onServersUpdate = null;
+ this.onServerMembersUpdate = null;
+ this.onSync = null;
+ this.onTransfersUpdate = null;
+ }
+
+ // Method Bindings
+ getAllMessages = () => getAllMessages(this);
+ processMessage = (msg) => processMessage(this, msg);
+ sendDMRequest = (targetKey, profile) => sendDMRequest(this, targetKey, profile);
+ acceptDMRequest = (targetKey) => acceptDMRequest(this, targetKey);
+ sendMessage = (channel, text) => sendMessage(this, channel, text);
+ sendDM = (targetKey, text) => sendDM(this, targetKey, text);
+ sendEditMessage = (targetId, newText) => sendEditMessage(this, targetId, newText);
+ sendDeleteMessage = (targetId) => sendDeleteMessage(this, targetId);
+ sendEphemeral = (payload) => sendEphemeral(this, payload);
+ sendReadReceipt = (channel, messageId) => sendReadReceipt(this, channel, messageId);
+ sendDeliveredReceipt = (channel, messageId) => sendDeliveredReceipt(this, channel, messageId);
+ sendOffline = () => sendOffline(this);
+ sendTyping = (channel) => sendTyping(this, channel);
+
+ pruneFile = (msgId) => this._pruneFile(msgId);
+ getStorageStats = () => this._getStorageStats();
+
+ _appendSignedMessage = (payloadObj) => _appendSignedMessage(this, payloadObj);
+ _appendEncryptedMessage = (targetKey, payloadObj) => _appendEncryptedMessage(this, targetKey, payloadObj);
+ _downloadFile = (msgId, fileMeta, isSender) => downloadFile(this, msgId, fileMeta, isSender);
+
+ _emitMessages() {
+ if (!this.onMessage) return;
+ if (this._msgTimeout) clearTimeout(this._msgTimeout);
+ this._msgTimeout = setTimeout(() => {
+ this.onMessage(this.getAllMessages());
+ this._msgTimeout = null;
+ }, 50);
+ }
+
+ _wipeLocalServerData = async (topicHex) => {
+ this.servers = this.servers.filter(s => s.topicHex !== topicHex);
+ if (this.serverDb) await this.serverDb.del(topicHex);
+ delete this.serverMembers[topicHex];
+
+ // Remove message history for this server/group chat
+ const msgsToDelete =[];
+ for (const [msgId, msg] of this.messages.entries()) {
+ const ch = msg.payload?.channel;
+ if (ch === topicHex || (ch && ch.startsWith(topicHex + '-'))) {
+ msgsToDelete.push(msgId);
+ }
+ }
+
+ let localDeleted =[];
+ if (typeof window !== 'undefined') {
+ localDeleted = JSON.parse(localStorage.getItem('pear_local_deleted_msgs') || '[]');
+ }
+
+ for (const msgId of msgsToDelete) {
+ const msg = this.messages.get(msgId);
+ if (msg) {
+ if (msg.localPath && fs && fs.existsSync(msg.localPath)) {
+ try { fs.unlinkSync(msg.localPath); } catch (e) {}
+ }
+ if (msg.payload?.file?.coreKey) {
+ try {
+ const core = this.store.get({ key: b4a.from(msg.payload.file.coreKey, 'hex') });
+ await core.ready();
+ await core.clear(0, core.length);
+ } catch (e) {}
+ }
+ this.deletedMessages.add(msgId);
+ this.messages.delete(msgId);
+ if (!localDeleted.includes(msgId)) localDeleted.push(msgId);
+ if (this.transfers[msgId]) delete this.transfers[msgId];
+ }
+ }
+
+ if (typeof window !== 'undefined' && msgsToDelete.length > 0) {
+ localStorage.setItem('pear_local_deleted_msgs', JSON.stringify(localDeleted));
+ }
+
+ if (this.onTransfersUpdate) this.onTransfersUpdate(this.transfers);
+ this._emitMessages();
+
+ this._emitServers();
+ this._emitServerMembers();
+ };
+
+ _reloadCores = async () => {
+ this._emitSync();
+ };
+
+ // Using spread arguments ensures all flags (like isGroupChat) are properly passed down
+ createServer = (...args) => createServer(this, ...args);
+ joinServer = (...args) => joinServer(this, ...args);
+ deleteServer = (...args) => deleteServer(this, ...args);
+ leaveServer = (...args) => leaveServer(this, ...args);
+ sendServerInvite = (...args) => sendServerInvite(this, ...args);
+ updateServerSettings = (...args) => updateServerSettings(this, ...args);
+ sendGroupChatAdd = (...args) => sendGroupChatAdd(this, ...args);
+
+ searchUser = (username) => searchUser(this, username);
+ queueFriendRequest = (username) => queueFriendRequest(this, username);
+ trackPeerCore = (coreKeyHex) => trackPeerCore(this, coreKeyHex);
+
+ sendFile = (...args) => sendFile(this, ...args);
+ sendDMFile = (...args) => sendDMFile(this, ...args);
+
+ addWebRTCListener = (fn) => addWebRTCListener(this, fn);
+ removeWebRTCListener = (fn) => removeWebRTCListener(this, fn);
+ sendWebRTCSignal = (target, payload) => sendWebRTCSignal(this, target, payload);
+
+ updateProfile = (name, avatar, username) => Identity.updateProfile(this, name, avatar, username);
+ _checkPendingRequests = (uname, pubKey, profile) => {
+ if (this.pendingFriendRequests.has(uname)) {
+ this.pendingFriendRequests.delete(uname);
+ this.pendingRequestsDb.del(uname);
+ this.sendDMRequest(pubKey, profile);
+ }
+ }
+
+ _emitKnownProfiles() {
+ if (this.onKnownProfilesUpdate) {
+ this.onKnownProfilesUpdate(Array.from(this.knownProfiles.entries()).map(([key, profile]) => ({ key, ...profile })));
+ }
+ }
+
+ _emitServers() {
+ if (this.onServersUpdate) this.onServersUpdate([...this.servers]);
+ }
+
+ _emitServerMembers() {
+ if (this.onServerMembersUpdate) {
+ const formatted = {};
+ for (const topic in this.serverMembers) {
+ formatted[topic] = Array.from(this.serverMembers[topic]);
+ }
+ this.onServerMembersUpdate(formatted);
+ }
+ }
+
+ _emitSync() {
+ if (this.onSync) this.onSync(true);
+ if (this.syncTimeout) clearTimeout(this.syncTimeout);
+ this.syncTimeout = setTimeout(() => {
+ if (this.onSync) this.onSync(false);
+ }, 500);
+ }
+
+ getBusyReasons() {
+ const reasons =[];
+ let activeUploads = 0;
+ let activeDownloads = 0;
+ let processing = 0;
+
+ for (const t of Object.values(this.transfers)) {
+ if (t.state === 'processing') {
+ processing++;
+ } else if (t.state === 'downloading') {
+ if (t.speed > 0 || (t.progress > 0 && t.progress < 1)) {
+ activeDownloads++;
+ }
+ } else if (t.state === 'uploading') {
+ if (t.speed > 0 || (t.progress > 0 && t.progress < 1)) {
+ activeUploads++;
+ }
+ }
+ }
+
+ if (processing > 0) reasons.push("Processing local files");
+ if (activeUploads > 0) reasons.push("Uploading files to peers");
+ if (activeDownloads > 0) reasons.push("Downloading files");
+ if (this.activeCalls > 0) reasons.push("Active voice/video call");
+
+ return reasons;
+ }
+
+ isBusy() {
+ return this.getBusyReasons().length > 0;
+ }
+
+ async _syncTimeWithServer() {
+ try {
+ if (!http) throw new Error("HTTP module not loaded");
+
+ await new Promise((resolve, reject) => {
+ // Using 1.1.1.1 bypasses DNS resolution entirely to prevent EAI_AGAIN on Linux VMs
+ const req = http.request({
+ hostname: '1.1.1.1',
+ method: 'HEAD',
+ port: 80,
+ timeout: 5000
+ }, (res) => {
+ const dateHeader = res.headers.date;
+ if (dateHeader) {
+ const serverTime = new Date(dateHeader).getTime();
+ const localTime = Date.now();
+ this.timeOffset = serverTime - localTime;
+ console.log(`[Time Sync] Offset calculated: ${this.timeOffset}ms`);
+ } else {
+ console.warn('[Time Sync] No date header found in response.');
+ }
+ resolve();
+ });
+
+ req.on('timeout', () => {
+ req.destroy();
+ reject(new Error('Connection timed out'));
+ });
+
+ req.on('error', (err) => {
+ reject(err);
+ });
+
+ req.end();
+ });
+ } catch (err) {
+ console.warn('[Time Sync] Failed to reach time server, falling back to local system clock.', err.message || err);
+ this.timeOffset = 0;
+ }
+ }
+
+ async checkUsernameAvailable(username) {
+ const normalized = username.toLowerCase();
+ // Keep strict limits here as this is a temporary, single-purpose swarm
+ const tempSwarm = new Hyperswarm({ maxPeers: 3, maxClientConnections: 3, maxServerConnections: 0 });
+ const topic = b4a.alloc(32);
+ sodium.crypto_generichash(topic, b4a.from('peercord-user:' + normalized));
+
+ let isTaken = false;
+ tempSwarm.on('connection', (conn) => {
+ isTaken = true;
+ conn.destroy();
+ });
+
+ tempSwarm.join(topic, { client: true, server: false });
+
+ // Wait up to 3 seconds to see if anyone responds on this topic
+ for (let i = 0; i < 30; i++) {
+ if (isTaken) break;
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+
+ await tempSwarm.destroy();
+ return !isTaken;
+ }
+
+ async reconnect() {
+ if (!this.swarm) return;
+ console.log("[P2P] Network online event detected. Reconnecting...");
+ try {
+ await this.swarm.flush();
+ } catch (e) {
+ console.warn("[P2P] Reconnect flush failed:", e);
+ }
+ }
+
+ async initialize(seedHex, displayName, username, avatar = null) {
+ this.displayName = displayName;
+ this.username = (username || 'unknown').toLowerCase();
+ this.avatar = avatar;
+
+ // Run time sync in the background so it doesn't block UI boot
+ this._syncTimeWithServer().catch(() => {});
+
+ let instanceId = 'default';
+ if (typeof window !== 'undefined') {
+ instanceId = localStorage.getItem('pear_instance_id');
+ if (!instanceId) {
+ instanceId = generateUUID();
+ localStorage.setItem('pear_instance_id', instanceId);
+ }
+
+ const localDeleted = JSON.parse(localStorage.getItem('pear_local_deleted_msgs') || '[]');
+ localDeleted.forEach(id => this.deletedMessages.add(id));
+ }
+
+ let basePath = './p2p-storage';
+ if (os && path && typeof os.homedir === 'function') {
+ const home = os.homedir();
+ const appData = process.platform === 'win32'
+ ? process.env.APPDATA
+ : (process.platform === 'darwin' ? path.join(home, 'Library', 'Application Support') : path.join(home, '.config'));
+ basePath = path.join(appData || home, 'Peercord', 'p2p-storage');
+ }
+
+ const hashBuf = b4a.alloc(32);
+ sodium.crypto_generichash(hashBuf, b4a.from(seedHex, 'hex'));
+ const accountHash = b4a.toString(hashBuf, 'hex').substring(0, 16);
+ this.storagePath = path.join(basePath, `${instanceId}-${accountHash}`);
+
+ if (fs && fs.existsSync) {
+ const badDownloadsPath = path.join(this.storagePath, 'downloads');
+ if (fs.existsSync(badDownloadsPath)) {
+ try { fs.rmSync(badDownloadsPath, { recursive: true, force: true }); } catch (e) {}
+ }
+ }
+
+ this.store = new Corestore(this.storagePath);
+ await this.store.ready();
+
+ const dbCore = this.store.get({ name: 'dm-db' }); await dbCore.ready();
+ this.db = new Hyperbee(dbCore, { keyEncoding: 'utf-8', valueEncoding: 'json' }); await this.db.ready();
+ const serverDbCore = this.store.get({ name: 'server-db' }); await serverDbCore.ready();
+ this.serverDb = new Hyperbee(serverDbCore, { keyEncoding: 'utf-8', valueEncoding: 'json' }); await this.serverDb.ready();
+ const dirDbCore = this.store.get({ name: 'directory-db' }); await dirDbCore.ready();
+ this.dirDb = new Hyperbee(dirDbCore, { keyEncoding: 'utf-8', valueEncoding: 'json' }); await this.dirDb.ready();
+ const pendingDbCore = this.store.get({ name: 'pending-requests-db' }); await pendingDbCore.ready();
+ this.pendingRequestsDb = new Hyperbee(pendingDbCore, { keyEncoding: 'utf-8', valueEncoding: 'json' }); await this.pendingRequestsDb.ready();
+ const localFilesDbCore = this.store.get({ name: 'local-files-db' }); await localFilesDbCore.ready();
+ this.localFilesDb = new Hyperbee(localFilesDbCore, { keyEncoding: 'utf-8', valueEncoding: 'utf-8' }); await this.localFilesDb.ready();
+ const coresDbCore = this.store.get({ name: 'peer-cores-db' }); await coresDbCore.ready();
+ this.coresDb = new Hyperbee(coresDbCore, { keyEncoding: 'utf-8', valueEncoding: 'utf-8' }); await this.coresDb.ready();
+ 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.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); }
+ for await (const { key, value } of this.profilesDb.createReadStream()) { this.knownProfiles.set(key, value); }
+
+ this.localCore = this.store.get({ name: 'user-messages', valueEncoding: 'json' }); await this.localCore.ready();
+ this.coreKey = b4a.toString(this.localCore.key, 'hex');
+
+ const seed = b4a.from(seedHex, 'hex');
+ const publicKey = b4a.alloc(32);
+ const secretKey = b4a.alloc(64);
+ sodium.crypto_sign_seed_keypair(publicKey, secretKey, seed);
+ this.myKey = b4a.toString(publicKey, 'hex');
+ this.secretKey = secretKey;
+ this.knownProfiles.set(this.myKey, { displayName: this.displayName, username: this.username, avatar: this.avatar });
+
+ // 1. INITIALIZE SWARM FIRST
+ this.swarm = new Hyperswarm({ keyPair: { publicKey, secretKey } });
+ this.swarm.on('connection', (conn, info) => {
+ this.store.replicate(conn);
+ const peerKey = b4a.toString(info.publicKey, 'hex');
+ this.peers.set(peerKey, { conn, displayName: 'Unknown', username: 'unknown', avatar: null, coreKey: null });
+ const identityMsg = JSON.stringify({ type: 'identity', displayName: this.displayName, username: this.username, avatar: this.avatar, coreKey: this.coreKey });
+ 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());
+ });
+ });
+
+ // 2. JOIN ALL KNOWN TOPICS (Batched and Flushed to prevent NAT exhaustion)
+ const paceJoin = () => new Promise(resolve => setTimeout(resolve, 100));
+ let joinCount = 0;
+
+ 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();
+ }
+ }
+
+ // Join all server/group chat topics so we receive messages on boot!
+ for (const server of this.servers) {
+ await this._joinTopic(server.topicHex, true); // Skip flush inside the method
+ joinCount++;
+
+ // Batch flush every 5 topics to let the router's NAT table breathe
+ if (joinCount % 5 === 0) {
+ try { await this.swarm.flush(); } catch(e) {}
+ } else {
+ await paceJoin();
+ }
+ }
+
+ // Join global updates topic for instant OTA broadcasts
+ const globalUpdateTopic = b4a.alloc(32);
+ sodium.crypto_generichash(globalUpdateTopic, b4a.from('peercord-global-updates'));
+ this.swarm.join(globalUpdateTopic, { client: true, server: true });
+
+ // 3. PROCESS LOCAL MESSAGES
+ for (let i = 0; i < this.localCore.length; i++) { this.processMessage(await this.localCore.get(i)); }
+
+ // 4. EMIT INITIAL STATE IMMEDIATELY (Offline-First Boot)
+ this._emitKnownProfiles();
+ if (this.onInit) this.onInit(this.myKey);
+ if (this.onDMsUpdate) this.onDMsUpdate({ ...this.dms });
+ this._emitServers();
+ this._emitMessages();
+
+ // 5. LOAD PEER CORES
+ const corePromises =[];
+ for await (const { key, value } of this.coresDb.createReadStream()) {
+ corePromises.push(this.trackPeerCore(value));
+ }
+ await Promise.all(corePromises);
+
+ // 6. FLUSH SWARM (Final flush for any remaining un-flushed topics)
+ this.swarm.flush().then(() => {
+ console.log("[P2P] Swarm flushed and announced.");
+ }).catch(err => console.warn("[P2P] Swarm flush failed (offline?):", err));
+ }
+
+ getPeerList() {
+ return Array.from(this.peers.entries()).map(([key, info]) => ({
+ key, displayName: info.displayName, username: info.username, avatar: info.avatar
+ }));
+ }
+
+ async _joinTopic(topicHex, skipFlush = false) {
+ if (!this.swarm) return;
+ if (this.joinedTopics.has(topicHex)) return;
+ this.joinedTopics.add(topicHex);
+ const topic = b4a.from(topicHex, 'hex');
+ this.swarm.join(topic, { client: true, server: true });
+ if (!skipFlush) {
+ try { await this.swarm.flush(); } catch(e) {}
+ }
+ }
+
+ async close() {
+ if (this.swarm) {
+ for (const peer of this.swarm.connections) peer.destroy();
+ await this.swarm.destroy();
+ this.swarm = null;
+ }
+ if (this.db) { await this.db.close(); this.db = null; }
+ if (this.serverDb) { await this.serverDb.close(); this.serverDb = null; }
+ if (this.dirDb) { await this.dirDb.close(); this.dirDb = null; }
+ if (this.pendingRequestsDb) { await this.pendingRequestsDb.close(); this.pendingRequestsDb = null; }
+ if (this.localFilesDb) { await this.localFilesDb.close(); this.localFilesDb = null; }
+ if (this.coresDb) { await this.coresDb.close(); this.coresDb = null; }
+ if (this.profilesDb) { await this.profilesDb.close(); this.profilesDb = null; }
+ if (this.store) { await this.store.close(); this.store = null; }
+
+ this.peers.clear();
+ this.peerCores.clear();
+ this.knownProfiles.clear();
+ this.userDirectory.clear();
+ this.pendingWhois.clear();
+ this.pendingFriendRequests.clear();
+ this.messages.clear();
+ this.deletedMessages.clear();
+ this.dms = {};
+ this.servers =[];
+ this.serverMembers = {};
+ this.joinedTopics.clear();
+ this.transfers = {};
+ this.webrtcListeners.clear();
+ if (this._msgTimeout) clearTimeout(this._msgTimeout);
+ }
+
+ async wipeAllData() {
+ await this.close();
+ if (typeof window !== 'undefined') localStorage.removeItem('pear_discord_identity');
+ try {
+ if (this.storagePath && fs) await fs.promises.rm(this.storagePath, { recursive: true, force: true });
+ } catch (err) { console.error("Failed to delete storage directory:", err); }
+ window.location.reload();
+ }
+
+ async _getStorageStats() {
+ const stats = {
+ total: 0,
+ dms: {},
+ servers: {},
+ files:[]
+ };
+
+ for (const msg of this.messages.values()) {
+ if (msg.payload?.type === 'file' && msg.isMediaInDB) {
+ const size = msg.payload.file.size || 0;
+ stats.total += size;
+
+ const fileInfo = {
+ id: msg.payload.id,
+ name: msg.payload.file.name,
+ size: size,
+ coreKey: msg.payload.file.coreKey,
+ timestamp: msg.payload.timestamp,
+ channel: msg.channel,
+ recipient: msg.recipient
+ };
+ stats.files.push(fileInfo);
+
+ if (msg.recipient) {
+ const target = msg.sender === this.myKey ? msg.recipient : msg.sender;
+ stats.dms[target] = (stats.dms[target] || 0) + size;
+ } else {
+ const topicHex = msg.channel.substring(0, 64);
+ const channelName = msg.channel.substring(65);
+ if (!stats.servers[topicHex]) stats.servers[topicHex] = { total: 0, channels: {} };
+ stats.servers[topicHex].total += size;
+ stats.servers[topicHex].channels[channelName] = (stats.servers[topicHex].channels[channelName] || 0) + size;
+ }
+ }
+ }
+
+ stats.files.sort((a, b) => b.size - a.size);
+ return stats;
+ }
+
+ async _pruneFile(msgId) {
+ const msg = this.messages.get(msgId);
+ if (!msg) return;
+
+ try {
+ if (msg.localPath && fs && fs.existsSync(msg.localPath)) {
+ try { fs.unlinkSync(msg.localPath); } catch (e) { console.error("Failed to delete physical file:", e); }
+ }
+
+ if (msg.payload?.file?.coreKey) {
+ try {
+ const core = this.store.get({ key: b4a.from(msg.payload.file.coreKey, 'hex') });
+ await core.ready();
+ await core.clear(0, core.length);
+ } catch (e) { console.error("Failed to clear hypercore:", e); }
+ }
+
+ this.deletedMessages.add(msgId);
+ this.messages.delete(msgId);
+
+ if (typeof window !== 'undefined') {
+ const localDeleted = JSON.parse(localStorage.getItem('pear_local_deleted_msgs') || '[]');
+ if (!localDeleted.includes(msgId)) {
+ localDeleted.push(msgId);
+ localStorage.setItem('pear_local_deleted_msgs', JSON.stringify(localDeleted));
+ }
+ }
+
+ if (this.transfers[msgId]) {
+ delete this.transfers[msgId];
+ if (this.onTransfersUpdate) this.onTransfersUpdate(this.transfers);
+ }
+ this._emitMessages();
+
+ } catch (err) {
+ console.error("Failed to prune file:", err);
+ }
+ }
+}
+
+export const network = new P2PNetwork();
+export { initP2P, ADMIN_PUBLIC_KEY } from './utils.js';
+export { generateIdentitySeed } from './modules/identity.js';
\ No newline at end of file
diff --git a/Peercord Source/src/p2p/modules/discovery.js b/Peercord Source/src/p2p/modules/discovery.js
new file mode 100644
index 0000000..1f92f5c
--- /dev/null
+++ b/Peercord Source/src/p2p/modules/discovery.js
@@ -0,0 +1,88 @@
+const b4a = window.require('b4a');
+import { generateUUID, sodium } from '../utils.js';
+
+export async function searchUser(network, targetUsername) {
+ const normalized = targetUsername.toLowerCase();
+
+ if (network.userDirectory.has(normalized)) {
+ return network.userDirectory.get(normalized);
+ }
+
+ // Join the DHT topic to reliably find the user even if they aren't in our current peer list
+ const topic = b4a.alloc(32);
+ sodium.crypto_generichash(topic, b4a.from('peercord-user:' + normalized));
+ network.swarm.join(topic, { client: true, server: false });
+
+ return new Promise((resolve) => {
+ let resolved = false;
+
+ const finish = (result) => {
+ if (resolved) return;
+ resolved = true;
+ network.swarm.leave(topic);
+ resolve(result);
+ };
+
+ const timeout = setTimeout(() => {
+ finish(null);
+ }, 5000);
+
+ // Check periodically if they appeared in userDirectory after connecting
+ const interval = setInterval(() => {
+ if (network.userDirectory.has(normalized)) {
+ clearTimeout(timeout);
+ clearInterval(interval);
+ finish(network.userDirectory.get(normalized));
+ }
+ }, 500);
+
+ // Also broadcast whois to existing peers just in case
+ const queryId = generateUUID();
+ network.pendingWhois.set(queryId, (result) => {
+ clearTimeout(timeout);
+ clearInterval(interval);
+ finish(result);
+ });
+
+ const msg = b4a.from(JSON.stringify({ type: 'whois', queryId, username: normalized }));
+ for (const { conn } of network.peers.values()) {
+ conn.write(msg);
+ }
+ });
+}
+
+export async function queueFriendRequest(network, targetUsername) {
+ const uname = targetUsername.toLowerCase();
+ network.pendingFriendRequests.add(uname);
+ await network.pendingRequestsDb.put(uname, { timestamp: Date.now() });
+
+ const topic = b4a.alloc(32);
+ sodium.crypto_generichash(topic, b4a.from('peercord-user:' + uname));
+ network.swarm.join(topic, { client: true, server: false });
+}
+
+export async function trackPeerCore(network, coreKeyHex) {
+ if (network.peerCores.has(coreKeyHex)) return;
+ const core = network.store.get({ key: b4a.from(coreKeyHex, 'hex'), valueEncoding: 'json' });
+ await core.ready();
+ network.peerCores.set(coreKeyHex, core);
+
+ let processedSeq = -1;
+
+ // Process all existing messages
+ for (let i = 0; i < core.length; i++) {
+ const msg = await core.get(i);
+ network.processMessage(msg);
+ processedSeq = i;
+ }
+
+ // Listen for new messages and process sequentially to prevent skipping rapid appends
+ core.on('append', async () => {
+ network._emitSync();
+ for (let i = processedSeq + 1; i < core.length; i++) {
+ const msg = await core.get(i);
+ network.processMessage(msg);
+ processedSeq = i;
+ }
+ });
+}
\ No newline at end of file
diff --git a/Peercord Source/src/p2p/modules/files.js b/Peercord Source/src/p2p/modules/files.js
new file mode 100644
index 0000000..c7d7f89
--- /dev/null
+++ b/Peercord Source/src/p2p/modules/files.js
@@ -0,0 +1,271 @@
+const b4a = window.require('b4a');
+import { generateUUID, fs, path, os } from '../utils.js';
+
+async function _hostFile(network, id, fileObj, fileCore) {
+ network.transfers[id] = { progress: 0, speed: 0, state: 'processing' };
+ if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
+
+ let processedBytes = 0;
+ let lastTime = Date.now();
+ let lastBytes = 0;
+
+ const updateProcessingProgress = (chunkLength) => {
+ processedBytes += chunkLength;
+ const now = Date.now();
+ if (now - lastTime >= 250 || processedBytes >= fileObj.size) {
+ const timeDiff = (now - lastTime) / 1000;
+ const speed = timeDiff > 0 ? (processedBytes - lastBytes) / timeDiff : 0;
+ const progress = Math.min(1, processedBytes / fileObj.size);
+
+ network.transfers[id] = { progress, speed, state: 'processing' };
+ if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
+
+ lastTime = now;
+ lastBytes = processedBytes;
+ }
+ };
+
+ // Append to local core (fast local disk I/O)
+ if (fileObj.path && fs) {
+ const stream = fs.createReadStream(fileObj.path, { highWaterMark: 64 * 1024 });
+ for await (const chunk of stream) {
+ await fileCore.append(chunk);
+ updateProcessingProgress(chunk.length);
+ }
+ } else if (fileObj.fileObj && typeof fileObj.fileObj.stream === 'function') {
+ const stream = fileObj.fileObj.stream();
+ const reader = stream.getReader();
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ await fileCore.append(b4a.from(value));
+ updateProcessingProgress(value.length);
+ }
+ } else if (fileObj.buffer) {
+ const buf = b4a.from(fileObj.buffer);
+ const chunkSize = 64 * 1024;
+ for(let i=0; i= fileMeta.size) {
+ const msg = network.messages.get(msgId);
+ if (msg) {
+ msg.localPath = filePath;
+ msg.isMediaInDB = isMedia;
+ network._emitMessages();
+ }
+ await network.localFilesDb.put(msgId, filePath);
+ network.transfers[msgId] = { progress: 1, speed: 0, state: 'completed' };
+ if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
+ return;
+ } else {
+ // Partial file exists, delete it to restart cleanly
+ try { fs.unlinkSync(filePath); } catch(e) {}
+ }
+ }
+
+ network.transfers[msgId] = { progress: 0, speed: 0, state: 'downloading' };
+ if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
+
+ const readStream = core.createReadStream({ live: true });
+ const writeStream = fs.createWriteStream(filePath);
+
+ let downloadedBytes = 0;
+ let lastTime = Date.now();
+ let lastBytes = 0;
+ let isFinished = false;
+
+ const sendProgress = (progress, speed) => {
+ network.transfers[msgId] = { progress, speed, state: 'downloading' };
+ if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
+
+ // Send ephemeral progress to the sender
+ network.sendEphemeral({ type: 'transfer_progress', id: msgId, progress, speed });
+ };
+
+ writeStream.on('finish', async () => {
+ const msg = network.messages.get(msgId);
+ if (msg) {
+ msg.localPath = filePath;
+ msg.isMediaInDB = isMedia;
+ network._emitMessages();
+ }
+ await network.localFilesDb.put(msgId, filePath);
+ network.transfers[msgId] = { progress: 1, speed: 0, state: 'completed' };
+ if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
+
+ // Final progress sync
+ sendProgress(1, 0);
+ });
+
+ writeStream.on('error', (err) => {
+ console.error("File write error:", err);
+ });
+
+ if (fileMeta.size === 0) {
+ writeStream.end();
+ return;
+ }
+
+ // Manually pump the stream to avoid pipe race conditions
+ readStream.on('data', (chunk) => {
+ if (isFinished) return;
+
+ downloadedBytes += chunk.length;
+ writeStream.write(chunk);
+
+ const now = Date.now();
+ if (now - lastTime >= 500 || downloadedBytes >= fileMeta.size) {
+ const timeDiff = (now - lastTime) / 1000;
+ const speed = timeDiff > 0 ? (downloadedBytes - lastBytes) / timeDiff : 0;
+ const progress = Math.min(1, downloadedBytes / fileMeta.size);
+
+ sendProgress(progress, Math.max(0, speed));
+
+ lastTime = now;
+ lastBytes = downloadedBytes;
+ }
+
+ if (downloadedBytes >= fileMeta.size) {
+ isFinished = true;
+ readStream.destroy(); // Stop reading from hypercore
+ writeStream.end(); // Close file safely to trigger finish event
+ }
+ });
+}
\ No newline at end of file
diff --git a/Peercord Source/src/p2p/modules/identity.js b/Peercord Source/src/p2p/modules/identity.js
new file mode 100644
index 0000000..aac9409
--- /dev/null
+++ b/Peercord Source/src/p2p/modules/identity.js
@@ -0,0 +1,65 @@
+const b4a = window.require('b4a');
+import { sodium } from '../utils.js';
+
+export function generateIdentitySeed() {
+ const buffer = b4a.alloc(32);
+ sodium.randombytes_buf(buffer);
+ return b4a.toString(buffer, 'hex');
+}
+
+export function getSharedSecret(network, targetPubKeyHex) {
+ const myCurveSec = b4a.alloc(sodium.crypto_scalarmult_BYTES);
+ const theirCurvePub = b4a.alloc(sodium.crypto_scalarmult_BYTES);
+ const theirEdPub = b4a.from(targetPubKeyHex, 'hex');
+
+ sodium.crypto_sign_ed25519_sk_to_curve25519(myCurveSec, network.secretKey);
+ sodium.crypto_sign_ed25519_pk_to_curve25519(theirCurvePub, theirEdPub);
+
+ const sharedSecret = b4a.alloc(sodium.crypto_scalarmult_BYTES);
+ sodium.crypto_scalarmult(sharedSecret, myCurveSec, theirCurvePub);
+ return sharedSecret;
+}
+
+export function encryptPayload(payloadObj, sharedSecret) {
+ const message = b4a.from(JSON.stringify(payloadObj));
+ const nonce = b4a.alloc(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
+ sodium.randombytes_buf(nonce);
+ const cipher = b4a.alloc(message.length + sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES);
+ sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(cipher, message, null, null, nonce, sharedSecret);
+ return { nonce: b4a.toString(nonce, 'hex'), cipher: b4a.toString(cipher, 'hex') };
+}
+
+export function decryptPayload(nonceHex, cipherHex, sharedSecret) {
+ const nonce = b4a.from(nonceHex, 'hex');
+ const cipher = b4a.from(cipherHex, 'hex');
+ const message = b4a.alloc(cipher.length - sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES);
+ try {
+ sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(message, null, cipher, null, nonce, sharedSecret);
+ return JSON.parse(b4a.toString(message));
+ } catch (e) {
+ return null;
+ }
+}
+
+export function updateProfile(network, displayName, avatar, username) {
+ network.displayName = displayName;
+ network.avatar = avatar;
+
+ if (username && username !== 'unknown' && network.username === 'unknown') {
+ network.username = username;
+ const myTopic = b4a.alloc(32);
+ sodium.crypto_generichash(myTopic, b4a.from('peercord-user:' + network.username));
+ network.swarm.join(myTopic, { client: false, server: true });
+ }
+
+ network.knownProfiles.set(network.myKey, { displayName, username: network.username, avatar });
+ if (network.profilesDb) network.profilesDb.put(network.myKey, { displayName, username: network.username, avatar });
+ network._emitKnownProfiles();
+
+ if (!network.swarm) return;
+
+ const identityMsg = JSON.stringify({ type: 'identity', displayName: network.displayName, username: network.username, avatar: network.avatar, coreKey: network.coreKey });
+ const payload = b4a.from(identityMsg);
+ for (const { conn } of network.peers.values()) conn.write(payload);
+ network._emitMessages();
+}
\ No newline at end of file
diff --git a/Peercord Source/src/p2p/modules/messaging.js b/Peercord Source/src/p2p/modules/messaging.js
new file mode 100644
index 0000000..caf0018
--- /dev/null
+++ b/Peercord Source/src/p2p/modules/messaging.js
@@ -0,0 +1,272 @@
+const b4a = window.require('b4a');
+import { generateUUID, sodium, ADMIN_PUBLIC_KEY } from '../utils.js';
+import { getSharedSecret, encryptPayload, decryptPayload } from './identity.js';
+
+export function getAllMessages(network) {
+ const joinedTopics = new Set(network.servers.map(s => s.topicHex));
+
+ return Array.from(network.messages.values()).filter(m => {
+ const ch = m.payload.channel;
+ if (ch && ch.length > 64 && ch[64] === '-') {
+ const topicHex = ch.substring(0, 64);
+ if (!joinedTopics.has(topicHex)) return false;
+ }
+ return true;
+ }).map(m => {
+ const known = network.knownProfiles.get(m.sender);
+ const isInvite = m.payload.type === 'server_invite';
+ const isFile = m.payload.type === 'file';
+
+ return {
+ id: m.payload.id,
+ channel: m.recipient ? m.recipient : m.payload.channel,
+ recipient: m.recipient,
+ text: isInvite ? null : m.payload.text,
+ payload: isInvite || isFile ? m.payload : null,
+ localPath: m.localPath,
+ localBlobUrl: m.localBlobUrl,
+ isMediaInDB: m.isMediaInDB,
+ timestamp: m.payload.timestamp,
+ logicalTime: m.payload.logicalTime || 0,
+ edited: m.payload.edited || false,
+ sender: m.sender,
+ senderName: known ? known.displayName : 'Unknown',
+ senderAvatar: known ? known.avatar : null,
+ // Pass the raw crypto data to the UI for verification
+ isEncrypted: !!m.cipher,
+ cipher: m.cipher || null,
+ nonce: m.nonce || null
+ };
+ }).sort((a, b) => {
+ if (a.logicalTime !== b.logicalTime) return a.logicalTime - b.logicalTime;
+ if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp;
+ return a.id.localeCompare(b.id);
+ });
+}
+
+export async function processMessage(network, msg) {
+ if (!msg || !msg.sender) return;
+
+ if (msg.recipient) {
+ if (msg.recipient !== network.myKey && msg.sender !== network.myKey) return;
+
+ const targetKey = msg.sender === network.myKey ? msg.recipient : msg.sender;
+ const sharedSecret = getSharedSecret(network, targetKey);
+
+ const sigPayload = msg.nonce + msg.cipher + msg.recipient;
+ const isValid = sodium.crypto_sign_verify_detached(
+ b4a.from(msg.signature, 'hex'),
+ b4a.from(sigPayload),
+ b4a.from(msg.sender, 'hex')
+ );
+ if (!isValid) return;
+
+ const decrypted = decryptPayload(msg.nonce, msg.cipher, sharedSecret);
+ if (!decrypted) return;
+
+ msg.payload = decrypted;
+
+ if (decrypted.logicalTime) {
+ network.logicalClock = Math.max(network.logicalClock, decrypted.logicalTime) + 1;
+ }
+
+ if (decrypted.type === 'server_invite') {
+ if (!network.messages.has(decrypted.id)) {
+ network.messages.set(decrypted.id, msg);
+ network._emitMessages();
+ }
+ return;
+ }
+
+ if (decrypted.type === 'group_chat_add') {
+ const { topicHex, name, icon, owner, channels } = decrypted;
+ network.joinServer(topicHex, name, icon, owner, true, true, channels);
+ return;
+ }
+
+ 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 };
+ await network.db.put('dm:' + msg.sender, network.dms[msg.sender]);
+ network.knownProfiles.set(msg.sender, msg.payload.profile);
+ network._emitKnownProfiles();
+ if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
+ }
+ } 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';
+ await network.db.put('dm:' + msg.sender, network.dms[msg.sender]);
+ if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
+ }
+ } else if (msg.payload.type === 'dm_chat' || msg.payload.type === 'file') {
+ if (!network.deletedMessages.has(msg.payload.id) && !network.messages.has(msg.payload.id)) {
+ network.messages.set(msg.payload.id, msg);
+ network._emitMessages();
+
+ if (msg.payload.type === 'file') {
+ network._downloadFile(msg.payload.id, msg.payload.file, msg.sender === network.myKey);
+ }
+ }
+ }
+ return;
+ }
+
+ if (!msg.signature || !msg.payloadStr) return;
+ try {
+ const sigBuf = b4a.from(msg.signature, 'hex');
+ const pubBuf = b4a.from(msg.sender, 'hex');
+ const isValid = sodium.crypto_sign_verify_detached(sigBuf, b4a.from(msg.payloadStr), pubBuf);
+ if (!isValid) return;
+ msg.payload = JSON.parse(msg.payloadStr);
+
+ if (msg.payload.logicalTime) {
+ network.logicalClock = Math.max(network.logicalClock, msg.payload.logicalTime) + 1;
+ }
+ } catch (err) { return; }
+
+ const { type, id, targetId, channel, text, serverTopicHex, allowAnyoneToInvite, name, icon, channels } = msg.payload;
+
+ if (type === 'server_delete') {
+ const server = network.servers.find(s => s.topicHex === serverTopicHex);
+ if (server && msg.sender === server.owner) {
+ await network._wipeLocalServerData(serverTopicHex);
+ }
+ return;
+ }
+
+ if (type === 'server_leave') {
+ if (network.serverMembers[serverTopicHex]) {
+ network.serverMembers[serverTopicHex].delete(msg.sender);
+ network._emitServerMembers();
+ }
+ return;
+ }
+
+ if (type === 'server_join') {
+ if (!network.serverMembers[serverTopicHex]) network.serverMembers[serverTopicHex] = new Set();
+ network.serverMembers[serverTopicHex].add(msg.sender);
+ network._emitServerMembers();
+ return;
+ }
+
+ if (type === 'server_settings_update') {
+ const server = network.servers.find(s => s.topicHex === serverTopicHex);
+ if (server && msg.sender === server.owner) {
+ if (allowAnyoneToInvite !== undefined) server.allowAnyoneToInvite = allowAnyoneToInvite;
+ if (name !== undefined) server.name = name;
+ if (icon !== undefined) server.icon = icon;
+ if (channels !== undefined) server.channels = channels;
+
+ network.serverDb.put(serverTopicHex, server);
+ network._emitServers();
+ }
+ return;
+ }
+
+ if (type === 'delete') {
+ if (msg.sender === ADMIN_PUBLIC_KEY || msg.sender === network.messages.get(targetId)?.sender) {
+ network.deletedMessages.add(targetId);
+ network.messages.delete(targetId);
+ network._emitMessages();
+ }
+ return;
+ }
+
+ if (type === 'edit') {
+ const original = network.messages.get(targetId);
+ if (original && original.sender === msg.sender) {
+ original.payload.text = text;
+ original.payload.edited = true;
+ network._emitMessages();
+ }
+ return;
+ }
+
+ if (type === 'chat' || type === 'file') {
+ if (!network.deletedMessages.has(id) && !network.messages.has(id)) {
+ network.messages.set(id, msg);
+ network._emitMessages();
+
+ if (type === 'file') {
+ network._downloadFile(id, msg.payload.file, msg.sender === network.myKey);
+ }
+ }
+ }
+}
+
+export async function _appendSignedMessage(network, payloadObj) {
+ if (!network.localCore) return;
+
+ network.logicalClock++;
+ payloadObj.logicalTime = network.logicalClock;
+ payloadObj.timestamp = Date.now() + network.timeOffset;
+ payloadObj.senderName = network.displayName;
+
+ const payloadStr = JSON.stringify(payloadObj);
+ const sigBuf = b4a.alloc(sodium.crypto_sign_BYTES);
+ sodium.crypto_sign_detached(sigBuf, b4a.from(payloadStr), network.secretKey);
+
+ const finalMessage = {
+ sender: network.myKey,
+ senderName: network.displayName,
+ signature: b4a.toString(sigBuf, 'hex'),
+ payloadStr: payloadStr
+ };
+
+ await network.localCore.append(finalMessage);
+ processMessage(network, finalMessage);
+}
+
+export async function _appendEncryptedMessage(network, targetKey, payloadObj) {
+ if (!network.localCore) return;
+
+ network.logicalClock++;
+ payloadObj.logicalTime = network.logicalClock;
+ payloadObj.timestamp = Date.now() + network.timeOffset;
+
+ const sharedSecret = getSharedSecret(network, targetKey);
+ const { nonce, cipher } = encryptPayload(payloadObj, sharedSecret);
+
+ const sigPayload = nonce + cipher + targetKey;
+ const sigBuf = b4a.alloc(sodium.crypto_sign_BYTES);
+ sodium.crypto_sign_detached(sigBuf, b4a.from(sigPayload), network.secretKey);
+
+ const finalMessage = {
+ sender: network.myKey, recipient: targetKey, nonce, cipher, signature: b4a.toString(sigBuf, 'hex')
+ };
+
+ await network.localCore.append(finalMessage);
+ processMessage(network, finalMessage);
+}
+
+export async function sendDMRequest(network, targetKey, profile) {
+ network.dms[targetKey] = { status: 'pending_outgoing', profile };
+ 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 } });
+}
+
+export async function acceptDMRequest(network, targetKey) {
+ if (network.dms[targetKey]) {
+ network.dms[targetKey].status = 'accepted';
+ await network.db.put('dm:' + targetKey, network.dms[targetKey]);
+ if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
+ }
+ await _appendEncryptedMessage(network, targetKey, { type: 'dm_accept' });
+}
+
+export async function sendMessage(network, channel, text) { await _appendSignedMessage(network, { type: 'chat', id: generateUUID(), channel, text }); }
+export async function sendDM(network, targetKey, text) { await _appendEncryptedMessage(network, targetKey, { type: 'dm_chat', id: generateUUID(), text }); }
+export async function sendEditMessage(network, targetId, newText) { await _appendSignedMessage(network, { type: 'edit', id: generateUUID(), targetId, text: newText }); }
+export async function sendDeleteMessage(network, targetId) { await _appendSignedMessage(network, { type: 'delete', id: generateUUID(), targetId }); }
+
+export function sendEphemeral(network, payload) {
+ if (!network.swarm) return;
+ const msg = b4a.from(JSON.stringify({ type: 'ephemeral', payload }));
+ for (const { conn } of network.peers.values()) conn.write(msg);
+}
+
+export function sendOffline(network) { sendEphemeral(network, { type: 'offline' }); }
+export function sendTyping(network, channel) { sendEphemeral(network, { type: 'typing', channel, displayName: network.displayName }); }
+export function sendReadReceipt(network, channel, messageId = null) { sendEphemeral(network, { type: 'read', channel, messageId, timestamp: Date.now() }); }
+export function sendDeliveredReceipt(network, channel, messageId = null) { sendEphemeral(network, { type: 'delivered', channel, messageId, timestamp: Date.now() }); }
\ No newline at end of file
diff --git a/Peercord Source/src/p2p/modules/servers.js b/Peercord Source/src/p2p/modules/servers.js
new file mode 100644
index 0000000..a368853
--- /dev/null
+++ b/Peercord Source/src/p2p/modules/servers.js
@@ -0,0 +1,104 @@
+const b4a = window.require('b4a');
+import { generateUUID, sodium } from '../utils.js';
+
+export async function createServer(network, name, icon, allowAnyoneToInvite, isGroupChat = false) {
+ const topic = b4a.alloc(32);
+ sodium.randombytes_buf(topic);
+ const topicHex = b4a.toString(topic, 'hex');
+
+ const channels = { text: ['general-chat'], voice: ['general-voice'] };
+ const serverInfo = { name, icon, owner: network.myKey, allowAnyoneToInvite, isGroupChat, channels };
+
+ network.servers.push({ topicHex, ...serverInfo });
+ network._emitServers();
+
+ await network.serverDb.put(topicHex, serverInfo);
+
+ await network._joinTopic(topicHex);
+ await network._appendSignedMessage({ type: 'server_join', serverTopicHex: topicHex, timestamp: Date.now() });
+
+ return { topicHex, ...serverInfo };
+}
+
+export async function joinServer(network, topicHex, name, icon, owner, allowAnyoneToInvite, isGroupChat = false, channels = null) {
+ if (network.servers.some(s => s.topicHex === topicHex)) return;
+
+ const serverInfo = { name, icon, owner, allowAnyoneToInvite, isGroupChat, channels: channels || { text: ['general-chat'], voice: ['general-voice'] } };
+
+ network.servers.push({ topicHex, ...serverInfo });
+ network._emitServers();
+
+ await network.serverDb.put(topicHex, serverInfo);
+
+ await network._joinTopic(topicHex);
+ await network._appendSignedMessage({ type: 'server_join', serverTopicHex: topicHex, timestamp: Date.now() });
+
+ await network._reloadCores();
+}
+
+export async function deleteServer(network, topicHex) {
+ await network._appendSignedMessage({ type: 'server_delete', serverTopicHex: topicHex, timestamp: Date.now() });
+ await network._wipeLocalServerData(topicHex);
+}
+
+export async function leaveServer(network, topicHex) {
+ await network._appendSignedMessage({ type: 'server_leave', serverTopicHex: topicHex, timestamp: Date.now() });
+ await network._wipeLocalServerData(topicHex);
+}
+
+export async function sendServerInvite(network, targetKey, serverTopicHex) {
+ const server = network.servers.find(s => s.topicHex === serverTopicHex);
+ if (!server) return;
+
+ await network._appendEncryptedMessage(targetKey, {
+ id: generateUUID(),
+ type: 'server_invite',
+ timestamp: Date.now(),
+ inviterName: network.displayName,
+ serverName: server.name,
+ serverIcon: server.icon,
+ serverTopicHex: server.topicHex,
+ serverOwner: server.owner,
+ allowAnyoneToInvite: server.allowAnyoneToInvite,
+ isGroupChat: server.isGroupChat,
+ channels: server.channels
+ });
+}
+
+export async function sendGroupChatAdd(network, targetKey, serverTopicHex) {
+ const server = network.servers.find(s => s.topicHex === serverTopicHex);
+ if (!server) return;
+
+ await network._appendEncryptedMessage(targetKey, {
+ id: generateUUID(),
+ type: 'group_chat_add',
+ timestamp: Date.now(),
+ topicHex: server.topicHex,
+ name: server.name,
+ icon: server.icon,
+ owner: server.owner,
+ channels: server.channels
+ });
+}
+
+export async function updateServerSettings(network, serverTopicHex, name, icon, allowAnyoneToInvite, channels) {
+ await network._appendSignedMessage({
+ type: 'server_settings_update',
+ serverTopicHex,
+ name,
+ icon,
+ allowAnyoneToInvite,
+ channels,
+ timestamp: Date.now()
+ });
+
+ const server = network.servers.find(s => s.topicHex === serverTopicHex);
+ if (server) {
+ if (name !== undefined) server.name = name;
+ if (icon !== undefined) server.icon = icon;
+ if (allowAnyoneToInvite !== undefined) server.allowAnyoneToInvite = allowAnyoneToInvite;
+ if (channels !== undefined) server.channels = channels;
+ await network.serverDb.put(serverTopicHex, server);
+ network._emitServers();
+ }
+}
\ No newline at end of file
diff --git a/Peercord Source/src/p2p/modules/webrtc.js b/Peercord Source/src/p2p/modules/webrtc.js
new file mode 100644
index 0000000..677bbc1
--- /dev/null
+++ b/Peercord Source/src/p2p/modules/webrtc.js
@@ -0,0 +1,18 @@
+const b4a = window.require('b4a');
+
+export function addWebRTCListener(network, fn) {
+ network.webrtcListeners.add(fn);
+}
+
+export function removeWebRTCListener(network, fn) {
+ network.webrtcListeners.delete(fn);
+}
+
+export function sendWebRTCSignal(network, targetKey, payload) {
+ if (!network.swarm) return;
+ const peerInfo = network.peers.get(targetKey);
+ if (peerInfo && peerInfo.conn) {
+ const msg = b4a.from(JSON.stringify({ type: 'ephemeral', payload }));
+ peerInfo.conn.write(msg);
+ }
+}
\ No newline at end of file
diff --git a/Peercord Source/src/p2p/network.js b/Peercord Source/src/p2p/network.js
new file mode 100644
index 0000000..ee90c29
--- /dev/null
+++ b/Peercord Source/src/p2p/network.js
@@ -0,0 +1,393 @@
+const b4a = window.require('b4a');
+const crypto = window.require('crypto');
+const Hyperswarm = window.require('hyperswarm');
+
+export async function initNetwork() {
+ // Kept for legacy compatibility if imported elsewhere
+}
+
+class P2PNetwork {
+ constructor() {
+ this.swarm = null;
+ this.peers = new Set();
+ this.onPeerConnect = null;
+ this.onPeerDisconnect = null;
+ }
+
+ async initialize() {
+ try {
+ this.swarm = new Hyperswarm();
+
+ this.swarm.on('connection', (conn, info) => {
+ const peerKey = b4a.toString(info.publicKey, 'hex');
+ this.peers.add(peerKey);
+
+ if (this.onPeerConnect) this.onPeerConnect(peerKey);
+
+ conn.on('close', () => {
+ this.peers.delete(peerKey);
+ if (this.onPeerDisconnect) this.onPeerDisconnect(peerKey);
+ });
+
+ conn.on('data', (data) => {
+ console.log(`Received data from ${peerKey}:`, data.toString());
+ });
+ });
+
+ console.log('P2P Network Initialized');
+ } catch (err) {
+ console.error('Failed to initialize Hyperswarm.', err);
+ }
+ }
+
+ async joinGlobalServer() {
+ if (!this.swarm) return;
+ const globalTopicSeed = crypto.createHash('sha256').update('GLOBAL_MAIN_SERVER_V1').digest();
+
+ const discovery = this.swarm.join(globalTopicSeed, { client: true, server: true });
+ await discovery.flushed();
+ console.log('Joined Global Main Server Swarm');
+ }
+}
+
+export const networkLegacy = new P2PNetwork();
+
+--- START OF FILE src/p2p/modules/discovery.js ---
+const b4a = window.require('b4a');
+import { generateUUID, sodium } from '../utils.js';
+
+export async function searchUser(network, targetUsername) {
+ const normalized = targetUsername.toLowerCase();
+
+ if (network.userDirectory.has(normalized)) {
+ return network.userDirectory.get(normalized);
+ }
+
+ const topic = b4a.alloc(32);
+ sodium.crypto_generichash(topic, b4a.from('peercord-user:' + normalized));
+ network.swarm.join(topic, { client: true, server: false });
+
+ return new Promise((resolve) => {
+ let resolved = false;
+
+ const finish = (result) => {
+ if (resolved) return;
+ resolved = true;
+ network.swarm.leave(topic);
+ resolve(result);
+ };
+
+ const timeout = setTimeout(() => {
+ finish(null);
+ }, 5000);
+
+ const interval = setInterval(() => {
+ if (network.userDirectory.has(normalized)) {
+ clearTimeout(timeout);
+ clearInterval(interval);
+ finish(network.userDirectory.get(normalized));
+ }
+ }, 500);
+
+ const queryId = generateUUID();
+ network.pendingWhois.set(queryId, (result) => {
+ clearTimeout(timeout);
+ clearInterval(interval);
+ finish(result);
+ });
+
+ const msg = b4a.from(JSON.stringify({ type: 'whois', queryId, username: normalized }));
+ for (const { conn } of network.peers.values()) {
+ conn.write(msg);
+ }
+ });
+}
+
+export async function queueFriendRequest(network, targetUsername) {
+ const uname = targetUsername.toLowerCase();
+ network.pendingFriendRequests.add(uname);
+ await network.pendingRequestsDb.put(uname, { timestamp: Date.now() });
+
+ const topic = b4a.alloc(32);
+ sodium.crypto_generichash(topic, b4a.from('peercord-user:' + uname));
+ network.swarm.join(topic, { client: true, server: false });
+}
+
+export async function trackPeerCore(network, coreKeyHex) {
+ if (network.peerCores.has(coreKeyHex)) return;
+ const core = network.store.get({ key: b4a.from(coreKeyHex, 'hex'), valueEncoding: 'json' });
+ await core.ready();
+ network.peerCores.set(coreKeyHex, core);
+
+ let processedSeq = -1;
+
+ for (let i = 0; i < core.length; i++) {
+ const msg = await core.get(i);
+ network.processMessage(msg);
+ processedSeq = i;
+ }
+
+ core.on('append', async () => {
+ network._emitSync();
+ for (let i = processedSeq + 1; i < core.length; i++) {
+ const msg = await core.get(i);
+ network.processMessage(msg);
+ processedSeq = i;
+ }
+ });
+}
+
+--- START OF FILE src/p2p/modules/files.js ---
+const b4a = window.require('b4a');
+import { generateUUID, fs, path, os } from '../utils.js';
+
+async function _hostFile(network, id, fileObj, fileCore) {
+ network.transfers[id] = { progress: 0, speed: 0, state: 'processing' };
+ if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
+
+ let processedBytes = 0;
+ let lastTime = Date.now();
+ let lastBytes = 0;
+
+ const updateProcessingProgress = (chunkLength) => {
+ processedBytes += chunkLength;
+ const now = Date.now();
+ if (now - lastTime >= 250 || processedBytes >= fileObj.size) {
+ const timeDiff = (now - lastTime) / 1000;
+ const speed = timeDiff > 0 ? (processedBytes - lastBytes) / timeDiff : 0;
+ const progress = Math.min(1, processedBytes / fileObj.size);
+
+ network.transfers[id] = { progress, speed, state: 'processing' };
+ if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
+
+ lastTime = now;
+ lastBytes = processedBytes;
+ }
+ };
+
+ if (fileObj.path && fs) {
+ const stream = fs.createReadStream(fileObj.path, { highWaterMark: 64 * 1024 });
+ for await (const chunk of stream) {
+ await fileCore.append(chunk);
+ updateProcessingProgress(chunk.length);
+ }
+ } else if (fileObj.fileObj && typeof fileObj.fileObj.stream === 'function') {
+ const stream = fileObj.fileObj.stream();
+ const reader = stream.getReader();
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ await fileCore.append(b4a.from(value));
+ updateProcessingProgress(value.length);
+ }
+ } else if (fileObj.buffer) {
+ const buf = b4a.from(fileObj.buffer);
+ const chunkSize = 64 * 1024;
+ for(let i=0; i= fileMeta.size) {
+ const msg = network.messages.get(msgId);
+ if (msg) {
+ msg.localPath = filePath;
+ msg.isMediaInDB = isMedia;
+ network._emitMessages();
+ }
+ await network.localFilesDb.put(msgId, filePath);
+ network.transfers[msgId] = { progress: 1, speed: 0, state: 'completed' };
+ if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
+ return;
+ } else {
+ try { fs.unlinkSync(filePath); } catch(e) {}
+ }
+ }
+
+ network.transfers[msgId] = { progress: 0, speed: 0, state: 'downloading' };
+ if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
+
+ const readStream = core.createReadStream({ live: true });
+ const writeStream = fs.createWriteStream(filePath);
+
+ let downloadedBytes = 0;
+ let lastTime = Date.now();
+ let lastBytes = 0;
+ let isFinished = false;
+
+ const sendProgress = (progress, speed) => {
+ network.transfers[msgId] = { progress, speed, state: 'downloading' };
+ if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
+ network.sendEphemeral({ type: 'transfer_progress', id: msgId, progress, speed });
+ };
+
+ writeStream.on('finish', async () => {
+ const msg = network.messages.get(msgId);
+ if (msg) {
+ msg.localPath = filePath;
+ msg.isMediaInDB = isMedia;
+ network._emitMessages();
+ }
+ await network.localFilesDb.put(msgId, filePath);
+ network.transfers[msgId] = { progress: 1, speed: 0, state: 'completed' };
+ if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
+ sendProgress(1, 0);
+ });
+
+ writeStream.on('error', (err) => {
+ console.error("File write error:", err);
+ });
+
+ if (fileMeta.size === 0) {
+ writeStream.end();
+ return;
+ }
+
+ readStream.on('data', (chunk) => {
+ if (isFinished) return;
+
+ downloadedBytes += chunk.length;
+ writeStream.write(chunk);
+
+ const now = Date.now();
+ if (now - lastTime >= 500 || downloadedBytes >= fileMeta.size) {
+ const timeDiff = (now - lastTime) / 1000;
+ const speed = timeDiff > 0 ? (downloadedBytes - lastBytes) / timeDiff : 0;
+ const progress = Math.min(1, downloadedBytes / fileMeta.size);
+
+ sendProgress(progress, Math.max(0, speed));
+
+ lastTime = now;
+ lastBytes = downloadedBytes;
+ }
+
+ if (downloadedBytes >= fileMeta.size) {
+ isFinished = true;
+ readStream.destroy();
+ writeStream.end();
+ }
+ });
+}
\ No newline at end of file
diff --git a/Peercord Source/src/p2p/utils.js b/Peercord Source/src/p2p/utils.js
new file mode 100644
index 0000000..c8b4be7
--- /dev/null
+++ b/Peercord Source/src/p2p/utils.js
@@ -0,0 +1,25 @@
+const b4a = window.require('b4a');
+
+export let Hyperswarm, Corestore, Hyperbee, sodium, fs, os, path, http;
+
+// The PUBLIC key is 100% safe to be in the open-source code.
+// It is mathematically impossible to derive your private seed from it.
+export const ADMIN_PUBLIC_KEY = '[PLACE_HOLDER]';
+
+export async function initP2P() {
+ const req = window.require;
+ Hyperswarm = req('hyperswarm');
+ Corestore = req('corestore');
+ Hyperbee = req('hyperbee');
+ sodium = req('sodium-native');
+ fs = req('fs');
+ os = req('os');
+ path = req('path');
+ http = req('http');
+}
+
+export function generateUUID() {
+ const buffer = b4a.alloc(16);
+ sodium.randombytes_buf(buffer);
+ return b4a.toString(buffer, 'hex');
+}
\ No newline at end of file
diff --git a/Peercord Source/tailwind.config.js b/Peercord Source/tailwind.config.js
new file mode 100644
index 0000000..02133b4
--- /dev/null
+++ b/Peercord Source/tailwind.config.js
@@ -0,0 +1,22 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content:[
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {
+ colors: {
+ base: 'var(--color-base)',
+ surface: 'var(--color-surface)',
+ panel: 'var(--color-panel)',
+ accent: 'var(--color-accent)',
+ text: 'var(--color-text)',
+ muted: 'var(--color-muted)'
+ }
+ },
+ },
+ plugins:[
+ require('@tailwindcss/typography'),
+ ],
+}
\ No newline at end of file
diff --git a/Peercord Source/vite.config.js b/Peercord Source/vite.config.js
new file mode 100644
index 0000000..4971961
--- /dev/null
+++ b/Peercord Source/vite.config.js
@@ -0,0 +1,46 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ base: './',
+ optimizeDeps: {
+ exclude:[
+ 'hyperswarm',
+ 'b4a',
+ 'sodium-native',
+ 'corestore',
+ 'hypercore',
+ 'autobase',
+ 'hyperbee',
+ 'pear-runtime',
+ 'os',
+ 'http',
+ 'child_process'
+ ]
+ },
+ build: {
+ outDir: 'dist',
+ emptyOutDir: true,
+ rollupOptions: {
+ external:[
+ 'hyperswarm',
+ 'b4a',
+ 'sodium-native',
+ 'corestore',
+ 'hypercore',
+ 'autobase',
+ 'hyperbee',
+ 'pear-runtime',
+ 'events',
+ 'fs',
+ 'path',
+ 'crypto',
+ 'stream',
+ 'os',
+ 'http',
+ 'child_process'
+ ]
+ }
+ }
+})
\ No newline at end of file
diff --git a/README.md b/README.md
index a809ef2..f5177de 100644
--- a/README.md
+++ b/README.md
@@ -122,12 +122,14 @@ Peercord uses a highly secure, decentralized update system. Updates are seeded v
To prevent malicious actors from broadcasting fake updates, the system uses **Ed25519 cryptographic signatures**.
-### Setting up your own keys (For Forks/Developers)
-If you fork this repository, you **must** generate your own cryptographic keys to broadcast updates. The codebase currently contains placeholders.
+### Setting up your own keys & Pear Links (For Forks/Developers)
+If you fork this repository, you **must** generate your own cryptographic keys to broadcast updates and your own Pear link for the OTA updater. The codebase currently contains placeholders.
1. **Generate Keys**: Run `node scripts/genkeys.js` locally to generate an Ed25519 keypair.
2. **Public Key**: Place your generated Public Key in `src/p2p/utils.js` (`ADMIN_PUBLIC_KEY`). This is safe to be public and is used by clients to verify the update came from you.
3. **Private Key (Seed)**: Place your generated Private Seed in `scripts/broadcast-update.js` (`ADMIN_SEED_HEX`). **DO NOT COMMIT THIS FILE TO VERSION CONTROL.** Keep it strictly local.
+4. **Generate Pear Link**: Run `pear touch` in your terminal to generate a new Pear link.
+5. **Update package.json**: Replace all instances of the existing `pear://...` link (or `[PEAR_LINK]` placeholders) in `package.json` (specifically in the `upgrade` field and the `pear:stage`/`pear:seed` scripts) with your newly generated Pear link.
### Broadcasting an Update
When you are ready to release a new version:
@@ -150,4 +152,41 @@ When you are ready to release a new version:
npm install
# Start the Vite development server and Electron wrapper
-npm run start
\ No newline at end of file
+npm run start
+```
+
+### Building the App
+```bash
+# Build the React UI
+npm run build:ui
+
+# Package for Windows
+npm run package:win
+
+# Package for Linux
+npm run package:linux
+```
+
+### Building the Installer
+```bash
+# Navigate to the installer directory (assuming it's in the root)
+# dotnet build -c Release
+```
+
+---
+
+## 🤝 Contributing
+
+Contributions are welcome! Because this is a P2P application, please ensure that any changes to the database schemas (`Hyperbee`) or message payloads (`Hypercore`) are backwards compatible, or include migration logic, to prevent breaking the network for older clients.
+
+1. Fork the Project
+2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
+3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
+4. Push to the Branch (`git push origin feature/AmazingFeature`)
+5. Open a Pull Request
+
+---
+
+## 📄 License
+
+Distributed under the MIT License. See `LICENSE` for more information.
\ No newline at end of file