v1.0.1 Update

This commit is contained in:
0% [█ █ █ █ █ █ █ █ █ █] 100% 2026-06-15 22:34:10 -05:00
parent b848c6612c
commit dd2cdd23d7
19 changed files with 2704 additions and 532 deletions

View File

@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, desktopCapturer, protocol, net } from 'electron';
import { app, BrowserWindow, ipcMain, desktopCapturer, protocol, net, Tray, Menu } from 'electron';
import path from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import fs from 'fs';
@ -11,14 +11,18 @@ const pkgPath = path.join(__dirname, 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
// Register custom protocol BEFORE app is ready to ensure localStorage persistence
// FIX: Added stream: true to allow <video> and <audio> tags to stream media properly
protocol.registerSchemesAsPrivileged([
{ scheme: 'peercord', privileges: { standard: true, secure: true, supportFetchAPI: true, bypassCSP: true, corsEnabled: true, stream: true } }
]);
// Force app name and userData path BEFORE app.whenReady()
// This ensures localStorage and user data is identical regardless of which .exe launches the app
app.name = 'Peercord';
// Prevent duplicate taskbar icons on Windows for portable/ZIP builds.
if (process.platform === 'win32') {
app.setAppUserModelId(process.execPath);
}
const appDataPath = path.join(app.getPath('appData'), 'Peercord');
app.setPath('userData', appDataPath);
@ -40,8 +44,6 @@ try {
}
function getAppDir() {
// Since we disabled ASAR, app.isPackaged will be FALSE even in production!
// We must detect dev mode by checking if the executable is named 'electron'
const execName = path.basename(process.execPath).toLowerCase();
const isDev = execName === 'electron.exe' || execName === 'electron';
@ -50,19 +52,25 @@ function getAppDir() {
if (process.platform === 'linux' && process.env.APPIMAGE) return process.env.APPIMAGE;
if (process.platform === 'darwin') return path.join(process.resourcesPath, '..', '..');
// Return the DIRECTORY containing the binary for Windows and Linux
// PearRuntime expects the directory to hash and verify against the seeded drive!
return path.dirname(process.execPath);
}
let globalWin = null;
let tray = null;
let isQuitting = false;
let isWindowReady = false;
let logQueue = [];
let closeToTray = true;
app.on('before-quit', () => {
isQuitting = true;
});
// Focus existing window if a second instance is launched
app.on('second-instance', (event, commandLine, workingDirectory) => {
if (globalWin) {
if (globalWin.isMinimized()) globalWin.restore();
globalWin.show();
globalWin.focus();
}
});
@ -79,7 +87,6 @@ function logToRenderer(level, ...args) {
console[level](...formattedArgs);
// Queue logs if the React window hasn't finished loading yet!
if (globalWin && isWindowReady && !globalWin.isDestroyed()) {
globalWin.webContents.send('main-log', { level, args: formattedArgs });
} else {
@ -87,7 +94,6 @@ function logToRenderer(level, ...args) {
}
}
// Flush the log queue the millisecond React says it's ready
ipcMain.on('renderer-ready', () => {
isWindowReady = true;
logQueue.forEach(log => {
@ -98,8 +104,11 @@ ipcMain.on('renderer-ready', () => {
logQueue = [];
});
ipcMain.on('set-tray-setting', (e, val) => {
closeToTray = val;
});
async function boot() {
// Handle Squirrel.Windows startup events to prevent multiple background launches
if (process.platform === 'win32') {
const cmd = process.argv[1];
if (cmd === '--squirrel-install' || cmd === '--squirrel-updated' || cmd === '--squirrel-uninstall' || cmd === '--squirrel-obsolete') {
@ -108,21 +117,17 @@ async function boot() {
}
}
// Prevents "GPU process exited unexpectedly: exit_code=1" on Windows
app.disableHardwareAcceleration();
await app.whenReady();
const appPath = app.getAppPath();
// Handle custom protocol for consistent localStorage origin
// This prevents Linux from wiping localStorage when the executable path changes
protocol.handle('peercord', (request) => {
// Safely serve local files (images/videos) bypassing Electron's file:// restrictions
if (request.url.startsWith('peercord://local/')) {
let filePath = decodeURIComponent(request.url.replace('peercord://local/', ''));
if (process.platform === 'win32' && filePath.startsWith('/')) {
filePath = filePath.substring(1); // Remove leading slash on Windows
filePath = filePath.substring(1);
}
return net.fetch(pathToFileURL(filePath).href);
}
@ -135,7 +140,6 @@ async function boot() {
let filePath = path.join(appPath, url);
// Fallback to dist/ if not found (helps with absolute paths in index.html)
if (!fs.existsSync(filePath)) {
const distPath = path.join(appPath, 'dist', url);
if (fs.existsSync(distPath)) filePath = distPath;
@ -144,14 +148,14 @@ async function boot() {
return net.fetch(pathToFileURL(filePath).href);
});
// Dynamically select the correct icon format based on the OS
const iconFile = process.platform === 'win32' ? 'icon.ico' : 'icon.png';
const iconPath = path.join(appPath, 'assets', iconFile);
const win = new BrowserWindow({
width: 1100,
height: 750,
title: "Peercord",
icon: path.join(appPath, 'assets', iconFile),
icon: iconPath,
frame: false,
resizable: true,
maximizable: true,
@ -165,10 +169,47 @@ async function boot() {
globalWin = win;
// Load via custom protocol instead of file:// to prevent localStorage wipes on Linux
win.loadURL('peercord://app/dist/index.html');
// ALLOW F12 TO OPEN DEVTOOLS
function updateTrayVisibility() {
if (win.isVisible()) {
if (tray) {
tray.destroy();
tray = null;
}
} else {
if (!tray) {
tray = new Tray(iconPath);
const contextMenu = Menu.buildFromTemplate([
{ label: 'Show Peercord', click: () => { win.show(); win.focus(); } },
{ type: 'separator' },
{ label: 'Quit Peercord', click: () => { isQuitting = true; app.quit(); } }
]);
tray.setToolTip('Peercord');
tray.setContextMenu(contextMenu);
tray.on('click', () => {
win.show();
win.focus();
});
}
}
}
win.on('show', updateTrayVisibility);
win.on('hide', updateTrayVisibility);
win.on('close', (event) => {
if (!isQuitting) {
if (closeToTray) {
event.preventDefault();
win.hide();
} else {
isQuitting = true;
app.quit();
}
}
});
win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12') {
win.webContents.toggleDevTools();
@ -176,18 +217,15 @@ async function boot() {
}
});
// Bridge for TitleBar.jsx custom controls
ipcMain.on('window-action', (event, action) => {
if (action === 'minimize') win.minimize();
if (action === 'maximize') win.isMaximized() ? win.restore() : win.maximize();
if (action === 'close') win.close();
if (action === 'close') win.close();
});
// Sync window state back to React for the maximize/restore icon
win.on('maximize', () => win.webContents.send('window-state-changed', true));
win.on('unmaximize', () => win.webContents.send('window-state-changed', false));
// Bridge for ScreenShareModal.jsx desktop sources
ipcMain.handle('get-desktop-sources', async () => {
const sources = await desktopCapturer.getSources({
types:['window', 'screen'],
@ -201,11 +239,7 @@ async function boot() {
}));
});
// ---------------------------------------------------------
// PEAR OTA UPDATER LOGIC
// ---------------------------------------------------------
// Initialize Embedded Pear Runtime AFTER window creation so logs are queued properly
let pear = null;
try {
const { default: PearRuntime } = await import('pear-runtime');
@ -214,14 +248,14 @@ async function boot() {
logToRenderer('info', '[Pear] Resolved App Directory for Updater:', resolvedAppDir);
pear = new PearRuntime({
...pkg, // Spread pkg to ensure updates: true is passed
...pkg,
dir: path.join(app.getPath('userData'), 'pear-data'),
app: resolvedAppDir
});
pear.on('error', (err) => logToRenderer('error', '[Pear Error]', err));
} catch (e) {
logToRenderer('error', '[Pear] Failed to initialize PearRuntime (Likely missing native modules for this OS. Did you build Linux on Windows?):', e.message, e.stack);
logToRenderer('error', '[Pear] Failed to initialize PearRuntime:', e.message, e.stack);
}
if (pear && pear.updater) {
@ -247,8 +281,8 @@ async function boot() {
logToRenderer('warn', '[Pear] Updater not available on pear object');
}
// Safe restart for Gossip protocol (reboots to find new seeder)
ipcMain.on('normal-restart', () => {
isQuitting = true;
app.relaunch();
app.quit();
});
@ -312,6 +346,7 @@ WshShell.Run "cmd.exe /c """"" & WScript.Arguments(0) & """ """ & WScript.Argume
child.unref();
logToRenderer('info', '[Pear] Detached script spawned. Quitting app to allow swap...');
isQuitting = true;
app.quit();
} else {
logToRenderer('info', '[Pear] macOS/Linux detected. Using detached bash script for reliable directory swap...');
@ -346,6 +381,7 @@ rm "$0"
child.unref();
logToRenderer('info', '[Pear] Detached script spawned. Quitting app to allow swap...');
isQuitting = true;
app.quit();
}
} catch (err) {

View File

@ -1,11 +1,11 @@
{
"name": "peercord",
"version": "1.0.0",
"version": "1.0.1",
"description": "Peercord, A P2P Discord clone powered by Pear Runtime",
"author": "Mastercodeon",
"main": "index.js",
"type": "module",
"upgrade": "[PEAR_LINK]",
"upgrade": "pear://wmir47w7mai3b1skj66mx7fzso6k6o91kipaney7gtt69npimouy",
"updates": true,
"scripts": {
"bump": "node scripts/version.js",
@ -28,8 +28,8 @@
"pear:build:mac": "pear build --package=package.json --darwin-arm64-app out/mac/peercord --target out/build",
"pear:build:linux": "pear build --package=package.json --linux-x64-app out/linux/peercord --target out/build",
"pear:build:multi": "pear build --package=package.json --win32-x64-app out/win/peercord --linux-x64-app out/linux/peercord --target out/build",
"pear:stage": "pear stage [PEAR_LINK] out/build",
"pear:seed": "pear seed [PEAR_LINK]",
"pear:stage": "pear stage pear://wmir47w7mai3b1skj66mx7fzso6k6o91kipaney7gtt69npimouy out/build",
"pear:seed": "pear seed pear://wmir47w7mai3b1skj66mx7fzso6k6o91kipaney7gtt69npimouy",
"broadcast": "node scripts/broadcast-update.js",
"genkeys": "node scripts/genkeys.js",
"release:win": "npm run bump && npm run make:win && npm run pear:clean && npm run pear:prepare:win && npm run pear:build:win && npm run pear:stage && npm run broadcast && npm run pear:seed",

View File

@ -16,6 +16,7 @@ export default function CallView({ targetKey, targetProfile, myProfile, isCaller
const pcRef = useRef(null);
const localStreamRef = useRef(null);
const localClonedAudioStreamRef = useRef(null);
const localScreenStreamRef = useRef(null);
const localCameraStreamRef = useRef(null);
@ -125,9 +126,26 @@ export default function CallView({ targetKey, targetProfile, myProfile, isCaller
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
});
const noiseSuppression = localStorage.getItem('pear_noise_suppression') !== 'false';
const audioConstraints = {
noiseSuppression: noiseSuppression,
echoCancellation: true,
autoGainControl: true
};
if (audioInputId && audioInputId !== 'default') {
audioConstraints.deviceId = { exact: audioInputId };
}
let aStream;
try {
aStream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints });
} catch (err) {
console.warn("Failed to get audio with specific constraints, falling back to default.", err);
aStream = await navigator.mediaDevices.getUserMedia({ audio: true });
}
localStreamRef.current = aStream;
if (initialVideoOn) {
@ -147,10 +165,27 @@ export default function CallView({ targetKey, targetProfile, myProfile, isCaller
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
audioCtxRef.current = audioCtx;
if (audioCtx.state === 'suspended') {
await audioCtx.resume().catch(() => {});
}
// Clone the stream for the analyser to prevent Web Audio API from interfering with WebRTC's internal audio processing pipeline
const clonedAudioStream = new MediaStream(aStream.getAudioTracks().map(t => t.clone()));
localClonedAudioStreamRef.current = clonedAudioStream;
const source = audioCtx.createMediaStreamSource(clonedAudioStream);
const analyser = audioCtx.createAnalyser();
const source = audioCtx.createMediaStreamSource(aStream);
source.connect(analyser);
analyser.fftSize = 256;
// Connect to a muted gain node and then to destination.
// This prevents Chrome from aggressively optimizing/suspending the audio graph which causes silent mics.
const dummyGain = audioCtx.createGain();
dummyGain.gain.value = 0;
source.connect(analyser);
analyser.connect(dummyGain);
dummyGain.connect(audioCtx.destination);
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
@ -188,6 +223,7 @@ export default function CallView({ targetKey, targetProfile, myProfile, isCaller
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
if (audioCtxRef.current) audioCtxRef.current.close().catch(() => {});
if (localStreamRef.current) localStreamRef.current.getTracks().forEach(t => t.stop());
if (localClonedAudioStreamRef.current) localClonedAudioStreamRef.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();

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { network, ADMIN_PUBLIC_KEY } from '../p2p/index.js';
import CreateChannelModal from './CreateChannelModal.jsx';
@ -6,9 +6,6 @@ export default function ChannelList({ activeChannel, setActiveChannel, myKey, pr
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);
@ -31,10 +28,80 @@ export default function ChannelList({ activeChannel, setActiveChannel, myKey, pr
const textChannels = activeServerObj?.channels?.text || ['general-chat'];
const voiceChannels = activeServerObj?.channels?.voice || ['general-voice'];
let isServerAdmin = activeServerObj?.owner === myKey || myKey === ADMIN_PUBLIC_KEY;
let canManageChannels = isServerAdmin;
let canManageRoles = isServerAdmin;
let hasReadPerm = isServerAdmin;
let canInvite = isServerAdmin || activeServerObj?.allowAnyoneToInvite;
if (!isServerAdmin && activeServerObj) {
const userRoles = activeServerObj.memberRoles?.[myKey] || [];
isServerAdmin = userRoles.some(rId => {
const r = activeServerObj.roles?.find(role => role.id === rId);
return r && r.permissions.includes('admin');
});
if (isServerAdmin) {
canManageChannels = true;
canManageRoles = true;
hasReadPerm = true;
canInvite = true;
} else {
canManageChannels = userRoles.some(rId => {
const r = activeServerObj.roles?.find(role => role.id === rId);
return r && r.permissions.includes('manage_channels');
});
canManageRoles = userRoles.some(rId => {
const r = activeServerObj.roles?.find(role => role.id === rId);
return r && r.permissions.includes('manage_roles');
});
hasReadPerm = userRoles.some(rId => {
const r = activeServerObj.roles?.find(role => role.id === rId);
return r && r.permissions.includes('read_messages');
});
}
}
const canOpenSettings = isServerAdmin || canManageChannels || canManageRoles;
const visibleTextChannels = textChannels.filter(ch => {
if (isServerAdmin) return true;
if (!hasReadPerm && activeServerObj?.roles && activeServerObj.roles.length > 0) return false;
const channelPerms = activeServerObj?.channels?.permissions?.[ch];
if (channelPerms && channelPerms.length > 0) {
const userRoles = activeServerObj?.memberRoles?.[myKey] || [];
return userRoles.some(rId => channelPerms.includes(rId));
}
return true;
});
const visibleVoiceChannels = voiceChannels.filter(ch => {
if (isServerAdmin) return true;
if (!hasReadPerm && activeServerObj?.roles && activeServerObj.roles.length > 0) return false;
const channelPerms = activeServerObj?.channels?.permissions?.[ch];
if (channelPerms && channelPerms.length > 0) {
const userRoles = activeServerObj?.memberRoles?.[myKey] || [];
return userRoles.some(rId => channelPerms.includes(rId));
}
return true;
});
const visibleTextChannelsStr = visibleTextChannels.join(',');
const visibleVoiceChannelsStr = visibleVoiceChannels.join(',');
useEffect(() => {
if (activeServerObj && !visibleTextChannels.includes(activeChannel) && !visibleVoiceChannels.includes(activeChannel)) {
if (visibleTextChannels.length > 0) {
setActiveChannel(visibleTextChannels[0]);
}
}
}, [activeServerObj, activeChannel, visibleTextChannelsStr, visibleVoiceChannelsStr, setActiveChannel]);
const handleCreateChannel = (name, type) => {
const newChannels = {
text: [...textChannels],
voice: [...voiceChannels]
voice: [...voiceChannels],
permissions: { ...(activeServerObj.channels?.permissions || {}) },
send_permissions: { ...(activeServerObj.channels?.send_permissions || {}) }
};
if (type === 'text' && !newChannels.text.includes(name)) newChannels.text.push(name);
@ -156,7 +223,7 @@ export default function ChannelList({ activeChannel, setActiveChannel, myKey, pr
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
</button>
)}
{isAdmin && (
{canOpenSettings && (
<button onClick={onOpenServerSettings} className="w-full text-left px-2 py-1.5 text-sm text-muted hover:bg-panel hover:text-text rounded transition-colors flex items-center justify-between">
Hub Settings
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
@ -166,15 +233,15 @@ export default function ChannelList({ activeChannel, setActiveChannel, myKey, pr
<div className="px-2 py-1 text-xs font-bold text-muted uppercase mt-2 flex justify-between items-center">
<span>Text Rooms</span>
{isAdmin && <button onClick={() => { setCreateChannelType('text'); setIsCreateChannelOpen(true); }} className="hover:text-text" title="Create Text Channel">+</button>}
{canManageChannels && <button onClick={() => { setCreateChannelType('text'); setIsCreateChannelOpen(true); }} className="hover:text-text" title="Create Text Channel">+</button>}
</div>
{textChannels.map(ch => renderChannel(ch, ch))}
{visibleTextChannels.map(ch => renderChannel(ch, ch))}
<div className="px-2 py-1 mt-4 text-xs font-bold text-muted uppercase flex justify-between items-center">
<span>Voice Rooms</span>
{isAdmin && <button onClick={() => { setCreateChannelType('voice'); setIsCreateChannelOpen(true); }} className="hover:text-text" title="Create Voice Channel">+</button>}
{canManageChannels && <button onClick={() => { setCreateChannelType('voice'); setIsCreateChannelOpen(true); }} className="hover:text-text" title="Create Voice Channel">+</button>}
</div>
{voiceChannels.map(ch => renderVoiceChannel(ch, ch))}
{visibleVoiceChannels.map(ch => renderVoiceChannel(ch, ch))}
</div>
<div

File diff suppressed because it is too large Load Diff

View File

@ -37,6 +37,7 @@ export default function GroupCallView({ channel, serverTopicHex, vcChannelId, my
const ignoreOffer = useRef({});
const localStreamRef = useRef(null);
const localClonedAudioStreamRef = useRef(null);
const localScreenStreamRef = useRef(null);
const localCameraStreamRef = useRef(null);
const audioCtxRef = useRef(null);
@ -128,9 +129,26 @@ export default function GroupCallView({ channel, serverTopicHex, vcChannelId, my
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
});
const noiseSuppression = localStorage.getItem('pear_noise_suppression') !== 'false';
const audioConstraints = {
noiseSuppression: noiseSuppression,
echoCancellation: true,
autoGainControl: true
};
if (audioInputId && audioInputId !== 'default') {
audioConstraints.deviceId = { exact: audioInputId };
}
let aStream;
try {
aStream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints });
} catch (err) {
console.warn("Failed to get audio with specific constraints, falling back to default.", err);
aStream = await navigator.mediaDevices.getUserMedia({ audio: true });
}
localStreamRef.current = aStream;
if (initialVideoOn) {
@ -148,10 +166,27 @@ export default function GroupCallView({ channel, serverTopicHex, vcChannelId, my
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
audioCtxRef.current = audioCtx;
if (audioCtx.state === 'suspended') {
await audioCtx.resume().catch(() => {});
}
// Clone the stream for the analyser to prevent Web Audio API from interfering with WebRTC's internal audio processing pipeline
const clonedAudioStream = new MediaStream(aStream.getAudioTracks().map(t => t.clone()));
localClonedAudioStreamRef.current = clonedAudioStream;
const source = audioCtx.createMediaStreamSource(clonedAudioStream);
const analyser = audioCtx.createAnalyser();
const source = audioCtx.createMediaStreamSource(aStream);
source.connect(analyser);
analyser.fftSize = 256;
// Connect to a muted gain node and then to destination.
// This prevents Chrome from aggressively optimizing/suspending the audio graph which causes silent mics.
const dummyGain = audioCtx.createGain();
dummyGain.gain.value = 0;
source.connect(analyser);
analyser.connect(dummyGain);
dummyGain.connect(audioCtx.destination);
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
@ -203,6 +238,7 @@ export default function GroupCallView({ channel, serverTopicHex, vcChannelId, my
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
if (audioCtxRef.current) audioCtxRef.current.close().catch(() => {});
if (localStreamRef.current) localStreamRef.current.getTracks().forEach(t => t.stop());
if (localClonedAudioStreamRef.current) localClonedAudioStreamRef.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());

View File

@ -52,10 +52,63 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
const[vcStates, setVcStates] = useState({});
const[showMembersDrawer, setShowMembersDrawer] = useState(false);
const[pinMembers, setPinMembers] = useState(localStorage.getItem('pear_pin_members') === 'true');
const [isNetworkOnline, setIsNetworkOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true);
const initialized = useRef(false);
const notifiedMsgs = useRef(new Set());
const [isFocused, setIsFocused] = useState(true);
const activeStateRef = useRef({ view: 'dms', dm: 'friends', channel: 'general-chat', focused: true });
useEffect(() => {
activeStateRef.current = { view: activeView, dm: activeDm, channel: activeChannel, focused: isFocused };
}, [activeView, activeDm, activeChannel, isFocused]);
useEffect(() => {
const onFocus = () => setIsFocused(true);
const onBlur = () => setIsFocused(false);
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
Notification.requestPermission();
}
if (typeof window !== 'undefined' && window.require) {
const closeToTray = localStorage.getItem('pear_close_to_tray') !== 'false';
window.require('electron').ipcRenderer.send('set-tray-setting', closeToTray);
}
const handleStorage = () => {
setPinMembers(localStorage.getItem('pear_pin_members') === 'true');
};
window.addEventListener('storage', handleStorage);
return () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
window.removeEventListener('storage', handleStorage);
};
}, []);
const playPing = () => {
try {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(880, audioCtx.currentTime); // A5
gain.gain.setValueAtTime(0, audioCtx.currentTime);
gain.gain.linearRampToValueAtTime(0.1, audioCtx.currentTime + 0.02);
gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.15);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(audioCtx.currentTime);
osc.stop(audioCtx.currentTime + 0.15);
setTimeout(() => audioCtx.close(), 500);
} catch (e) {}
};
useEffect(() => {
const handleOnline = () => {
@ -100,7 +153,101 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
}
};
network.onKnownProfilesUpdate = (users) => setKnownUsers(users);
network.onMessage = (msgs) => setMessages([...msgs]);
network.onMessage = (msgs) => {
setMessages([...msgs]);
const notifyDMs = localStorage.getItem('pear_notify_dms') !== 'false';
const notifyHubs = localStorage.getItem('pear_notify_hubs') !== 'false';
const notifyMentions = localStorage.getItem('pear_notify_mentions') !== 'false';
let shouldPing = false;
let notifBody = '';
let notifTitle = 'New Message';
let jumpInfo = null;
msgs.forEach(msg => {
if (!notifiedMsgs.current.has(msg.id)) {
notifiedMsgs.current.add(msg.id);
// Only notify for recent messages (within last 10 seconds) to prevent boot spam
const isRecent = (Date.now() - msg.timestamp) < 10000;
if (msg.sender !== network.myKey && isRecent) {
const isDM = !msg.channel || msg.recipient;
const msgChannelId = isDM ? msg.sender : msg.channel;
let isMention = false;
if (msg.text && (msg.text.includes(`@${profile.username}`) || msg.text.includes('@everyone'))) {
isMention = true;
}
if (isDM && !notifyDMs) return;
if (!isDM && !notifyHubs && !isMention) return;
if (!isDM && isMention && !notifyMentions) return;
const { view, dm, channel, focused } = activeStateRef.current;
let isCurrentChannel = false;
if (view === 'dms') {
isCurrentChannel = dm === msgChannelId;
} else {
isCurrentChannel = `${view}-${channel}` === msgChannelId;
}
if (!focused || !isCurrentChannel) {
shouldPing = true;
notifBody = msg.text || 'Sent an attachment';
if (isDM) {
notifTitle = `Peercord - Message from ${msg.senderName}`;
jumpInfo = { isDM: true, channelId: msg.sender, msgId: msg.id };
} else {
const topicHex = msg.channel.substring(0, 64);
const chName = msg.channel.substring(65);
const server = network.servers.find(s => s.topicHex === topicHex);
const srvName = server ? server.name : 'Hub';
notifTitle = `Peercord - ${srvName} #${chName}`;
notifBody = `${msg.senderName}: ${notifBody}`;
jumpInfo = { isDM: false, serverId: topicHex, channelId: chName, msgId: msg.id };
}
}
}
}
});
if (shouldPing) {
playPing();
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
const notif = new Notification(notifTitle, {
body: notifBody
});
notif.onclick = () => {
if (typeof window !== 'undefined' && window.require) {
try {
const { ipcRenderer } = window.require('electron');
ipcRenderer.send('window-action', 'restore');
} catch (e) {}
}
window.focus();
if (jumpInfo) {
if (jumpInfo.isDM) {
setActiveView('dms');
setActiveDm(jumpInfo.channelId);
} else {
setActiveView(jumpInfo.serverId);
setActiveChannel(jumpInfo.channelId);
}
setTimeout(() => {
window.dispatchEvent(new CustomEvent('jump-to-message', { detail: jumpInfo.msgId }));
}, 500);
}
};
}
}
};
network.onDMsUpdate = (updatedDms) => setDms(updatedDms);
network.onTransfersUpdate = (t) => setTransfers({...t});
network.onServersUpdate = (srvs) => {
@ -183,7 +330,7 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
}
};
network.initialize(profile.seedHex, profile.displayName, profile.username, profile.avatar)
network.initialize(profile.seedHex, profile.displayName, profile.username, profile.avatar, profile.bio, profile.connections)
.catch(err => {
alert("P2P Initialization Error:\n" + err.message + "\n\nPress F12 to open DevTools for more info.");
console.error(err);
@ -241,7 +388,13 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
useEffect(() => {
const handleWebRTC = (peerKey, payload) => {
const notifyCalls = localStorage.getItem('pear_notify_calls') !== 'false';
if (payload.type === 'webrtc-init') {
if (!notifyCalls) {
network.sendWebRTCSignal(peerKey, { type: 'webrtc-busy' });
return;
}
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' });
@ -271,6 +424,7 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
setActiveCall(current => current?.targetKey === peerKey ? null : current);
}
else if (payload.type === 'webrtc-group-ring') {
if (!notifyCalls) return;
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' });
@ -281,8 +435,15 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
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 handleSaveProfile = (newName, newAvatar, newUsername, newBio, newConnections) => {
const updatedProfile = {
...profile,
displayName: newName,
avatar: newAvatar,
username: newUsername || profile.username,
bio: newBio || '',
connections: newConnections || []
};
const accounts = JSON.parse(localStorage.getItem('pear_saved_accounts') || '[]');
const existingIndex = accounts.findIndex(a => a.seedHex === profile.seedHex);
@ -293,7 +454,7 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
localStorage.setItem('pear_discord_identity', JSON.stringify(updatedProfile));
setProfile(updatedProfile);
network.updateProfile(newName, newAvatar, newUsername);
network.updateProfile(newName, newAvatar, newUsername, newBio, newConnections);
setIsSettingsOpen(false);
};
@ -442,6 +603,11 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
setShowChatInCall(false);
};
const handleNavigateToDM = (pubKey) => {
setActiveView('dms');
setActiveDm(pubKey);
};
const unreadCounts = {};
messages.forEach(m => {
const channelId = m.recipient ? (m.sender === myKey ? m.recipient : m.sender) : m.channel;
@ -458,6 +624,10 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
const isGroupChat = activeView === 'dms' && servers.some(s => s.topicHex === activeDm && s.isGroupChat);
const inviteServerObj = servers.find(s => s.topicHex === inviteModalServer);
const showMembersPanel = activeView !== 'dms' || isGroupChat;
const isPinned = pinMembers && showMembersPanel;
const isDrawerOpen = showMembersDrawer && showMembersPanel;
return (
<div className="flex h-full w-full bg-base font-sans overflow-hidden relative">
<Sidebar
@ -465,6 +635,7 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
setActiveView={setActiveView}
servers={servers}
myKey={myKey}
unreadCounts={unreadCounts}
onOpenCreateServer={() => setIsCreateServerOpen(true)}
onLeaveServer={(topicHex) => {
network.leaveServer(topicHex);
@ -533,13 +704,14 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
<div className="flex-1 relative overflow-hidden flex">
{/* Chat Area (Hidden if CallView is active) */}
<div className={`flex-1 flex flex-col ${showCallView ? 'hidden' : ''}`}>
<div className={`flex-1 flex flex-col min-w-0 ${showCallView ? 'hidden' : ''}`}>
{activeView === 'dms' && activeDm === 'friends' ? (
<FriendsView dms={dms} />
) : (
<ChatArea
activeView={activeView}
activeChannel={activeView === 'dms' ? activeDm : activeChannel}
activeChannel={activeView === 'dms' ? activeDm : activeChannel}
setActiveChannel={activeView === 'dms' ? setActiveDm : setActiveChannel}
messages={messages}
myKey={myKey}
profile={profile}
@ -560,6 +732,8 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
transfers={transfers}
onOpenInvite={(topicHex) => setInviteModalServer(topicHex)}
onToggleMembers={() => setShowMembersDrawer(!showMembersDrawer)}
pinMembers={pinMembers}
onNavigateToDM={handleNavigateToDM}
/>
)}
</div>
@ -567,7 +741,7 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
{/* 1-on-1 Call View */}
{activeCall && (
<CallView
className={showCallView && isViewingCallDM ? 'flex-1 flex flex-col' : 'hidden'}
className={showCallView && isViewingCallDM ? 'flex-1 flex flex-col min-w-0' : 'hidden'}
targetKey={activeCall.targetKey}
targetProfile={activeCall.profile}
myProfile={profile}
@ -583,7 +757,7 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
{/* Group Call View (Used for both DMs and Server VCs) */}
{(activeGroupCall || activeVc) && (
<GroupCallView
className={showCallView && (isViewingGroupCall || isViewingVC) ? 'flex-1 flex flex-col' : 'hidden'}
className={showCallView && (isViewingGroupCall || isViewingVC) ? 'flex-1 flex flex-col min-w-0' : 'hidden'}
channel={activeGroupCall?.channel || `${activeVc.serverId}-${activeVc.channelId}`}
serverTopicHex={activeVc?.serverId}
vcChannelId={activeVc?.channelId}
@ -629,19 +803,23 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
)}
{/* Members Drawer */}
<div className={`absolute top-0 right-0 bottom-0 w-64 bg-surface border-l border-base transform transition-transform duration-300 z-40 ${showMembersDrawer && (activeView !== 'dms' || isGroupChat) ? 'translate-x-0' : 'translate-x-full'}`}>
<OnlineUsers
onlinePeers={onlinePeers}
knownUsers={knownUsers}
dms={dms}
myKey={myKey}
profile={profile}
activeView={activeView === 'dms' ? activeDm : activeView}
servers={servers}
serverMembers={serverMembers}
onClose={() => setShowMembersDrawer(false)}
/>
</div>
{showMembersPanel && (
<div className={`${isPinned ? 'relative w-64 shrink-0' : `absolute top-0 right-0 bottom-0 w-64 transform transition-transform duration-300 z-40 ${isDrawerOpen ? 'translate-x-0' : 'translate-x-full'}`} bg-surface border-l border-base flex flex-col`}>
<OnlineUsers
onlinePeers={onlinePeers}
knownUsers={knownUsers}
dms={dms}
myKey={myKey}
profile={profile}
activeView={activeView === 'dms' ? activeDm : activeView}
servers={servers}
serverMembers={serverMembers}
onClose={() => setShowMembersDrawer(false)}
pinMembers={pinMembers}
onNavigateToDM={handleNavigateToDM}
/>
</div>
)}
</div>
@ -687,6 +865,7 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
<ServerSettingsModal
onClose={() => setSettingsModalServer(null)}
activeServerObj={servers.find(s => s.topicHex === settingsModalServer)}
myKey={myKey}
onDeleteServer={() => {
network.deleteServer(settingsModalServer);
setSettingsModalServer(null);

View File

@ -1,12 +1,9 @@
import React from 'react';
import React, { useState } from 'react';
import { network, ADMIN_PUBLIC_KEY } from '../p2p/index.js';
import UserProfileModal from './UserProfileModal.jsx';
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 });
};
export default function OnlineUsers({ onlinePeers, knownUsers, dms, myKey, profile, activeView, servers, serverMembers, onClose, pinMembers, onNavigateToDM }) {
const [selectedUser, setSelectedUser] = useState(null);
const isCustomServer = activeView !== 'dms';
const serverObj = isCustomServer ? servers.find(s => s.topicHex === activeView) : null;
@ -19,7 +16,7 @@ export default function OnlineUsers({ onlinePeers, knownUsers, dms, myKey, profi
currentMembers.add(myKey);
}
const me = { key: myKey, displayName: profile.displayName, username: profile.username, avatar: profile.avatar };
const me = { key: myKey, displayName: profile.displayName, username: profile.username, avatar: profile.avatar, bio: profile.bio, connections: profile.connections };
const allOnlinePeers = [me, ...onlinePeers];
const filteredOnlinePeers = isCustomServer ? allOnlinePeers.filter(p => currentMembers.has(p.key)) : allOnlinePeers;
@ -31,7 +28,7 @@ export default function OnlineUsers({ onlinePeers, knownUsers, dms, myKey, profi
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({ key, displayName: 'Unknown User', username: 'unknown', avatar: null, bio: '', connections: [] });
}
});
} else {
@ -39,13 +36,16 @@ export default function OnlineUsers({ onlinePeers, knownUsers, dms, myKey, profi
}
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 (
<div key={peer.key} className={`flex items-center justify-between group cursor-pointer hover:bg-panel p-2 rounded ${!isOnline ? 'opacity-60 hover:opacity-100' : ''}`}>
<div
key={peer.key}
onClick={() => setSelectedUser(peer)}
className={`flex items-center justify-between group cursor-pointer hover:bg-panel p-2 rounded ${!isOnline ? 'opacity-60 hover:opacity-100' : ''}`}
>
<div className="flex items-center gap-3 overflow-hidden">
<div className="relative shrink-0 w-8 h-8">
<div className={`w-full h-full rounded-md flex items-center justify-center text-white text-xs font-bold overflow-hidden ${peer.avatar ? 'bg-transparent' : 'bg-indigo-500'}`}>
@ -67,29 +67,21 @@ export default function OnlineUsers({ onlinePeers, knownUsers, dms, myKey, profi
<span className="text-muted text-[10px] truncate">@{peer.username}</span>
</div>
</div>
{!dmState && peer.key !== myKey && (
<button
onClick={(e) => handleSendRequest(e, peer)}
className="opacity-0 group-hover:opacity-100 bg-accent hover:opacity-90 text-white p-1.5 rounded-full transition-all shrink-0"
title="Send Contact Request"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5c-2.2 0-4 1.8-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><line x1="20" y1="8" x2="20" y2="14"></line><line x1="23" y1="11" x2="17" y2="11"></line></svg>
</button>
)}
</div>
);
};
return (
<div className="w-full h-full flex flex-col p-4 overflow-y-auto">
<div className="w-full h-full flex flex-col p-4 overflow-y-auto relative">
<div className="flex justify-between items-center mb-4">
<div className="text-xs font-bold text-muted uppercase">
{isGroupChat ? 'Members' : 'Online'} {filteredOnlinePeers.length}
</div>
<button onClick={onClose} className="text-muted hover:text-text">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
{!pinMembers && (
<button onClick={onClose} className="text-muted hover:text-text">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
)}
</div>
<div className="space-y-1 mb-6">
@ -106,6 +98,19 @@ export default function OnlineUsers({ onlinePeers, knownUsers, dms, myKey, profi
</div>
</>
)}
{selectedUser && (
<UserProfileModal
user={selectedUser}
onClose={() => setSelectedUser(null)}
onSendDM={selectedUser.key !== myKey ? (u) => {
if (!dms[u.key]) {
network.sendDMRequest(u.key, { displayName: u.displayName, username: u.username, avatar: u.avatar, bio: u.bio, connections: u.connections });
}
if (onNavigateToDM) onNavigateToDM(u.key);
} : null}
/>
)}
</div>
);
}

View File

@ -66,12 +66,10 @@ function StorageSettings({ dms, servers, knownUsers }) {
<h4 className="text-muted uppercase text-xs font-bold mb-3">Hubs</h4>
<div className="space-y-2 max-h-48 overflow-y-auto custom-scrollbar pr-2">
{Object.entries(stats.servers).map(([topicHex, data]) => {
const server = servers.find(s => s.topicHex === topicHex);
const name = server ? server.name : 'Unknown Hub';
return (
<div key={topicHex} className="flex flex-col bg-panel p-2 rounded gap-1">
<div className="flex justify-between items-center">
<span className="text-sm font-bold text-text truncate pr-2">{name}</span>
<span className="text-sm font-bold text-text truncate pr-2">{data.name}</span>
<span className="text-sm font-mono text-muted shrink-0">{formatBytes(data.total)}</span>
</div>
{Object.entries(data.channels).map(([ch, size]) => (
@ -92,23 +90,40 @@ function StorageSettings({ dms, servers, knownUsers }) {
<div className="bg-surface rounded-lg p-6">
<h3 className="text-muted uppercase text-xs font-bold mb-4">Large Files</h3>
<div className="space-y-2 max-h-64 overflow-y-auto custom-scrollbar pr-2">
{stats.files.slice(0, 50).map(file => (
<div key={file.id} className="flex justify-between items-center bg-panel p-3 rounded group">
<div className="flex flex-col overflow-hidden pr-4">
<span className="text-sm text-text font-medium truncate">{file.name}</span>
<span className="text-xs text-muted">{new Date(file.timestamp).toLocaleString()}</span>
{stats.files.slice(0, 50).map(file => {
let originText = 'Unknown Origin';
if (file.target) {
const name = dms[file.target]?.profile?.displayName || knownUsers.find(u => u.key === file.target)?.displayName || 'Unknown User';
originText = `Whisper: ${name}`;
} else if (file.channel) {
const channelName = file.channel.substring(65);
if (file.isGroupChat) originText = `Group: ${file.serverName}`;
else originText = `${file.serverName} ${channelName ? '#' + channelName : ''}`;
}
return (
<div key={file.id} className="flex justify-between items-center bg-panel p-3 rounded group">
<div className="flex flex-col overflow-hidden pr-4">
<span className="text-sm text-text font-medium truncate">{file.name}</span>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-base text-muted truncate max-w-[200px]" title={originText}>
{originText}
</span>
<span className="text-xs text-muted">{new Date(file.timestamp).toLocaleString()}</span>
</div>
</div>
<div className="flex items-center gap-4 shrink-0">
<span className="text-sm font-mono text-muted">{formatBytes(file.size)}</span>
<button
onClick={() => handlePrune(file.id)}
className="bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white px-3 py-1.5 rounded text-xs font-bold transition-colors opacity-0 group-hover:opacity-100"
>
Delete Local Data
</button>
</div>
</div>
<div className="flex items-center gap-4 shrink-0">
<span className="text-sm font-mono text-muted">{formatBytes(file.size)}</span>
<button
onClick={() => handlePrune(file.id)}
className="bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white px-3 py-1.5 rounded text-xs font-bold transition-colors opacity-0 group-hover:opacity-100"
>
Delete Local Data
</button>
</div>
</div>
))}
);
})}
{stats.files.length === 0 && <div className="text-sm text-muted text-center py-4">No large files found.</div>}
</div>
</div>
@ -120,6 +135,7 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
const[activeTab, setActiveTab] = useState('account');
const[tempName, setTempName] = useState(profile.displayName);
const[tempAvatar, setTempAvatar] = useState(profile.avatar);
const[tempBio, setTempBio] = useState(profile.bio || '');
const[showSeed, setShowSeed] = useState(false);
const fileInputRef = useRef(null);
@ -135,6 +151,15 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
const [autoRestart, setAutoRestart] = useState(localStorage.getItem('pear_auto_restart') !== 'false');
const [liveDecryption, setLiveDecryption] = useState(localStorage.getItem('pear_live_decryption') === 'true');
const [ircMode, setIrcMode] = useState(localStorage.getItem('pear_irc_mode') === 'true');
const [noiseSuppression, setNoiseSuppression] = useState(localStorage.getItem('pear_noise_suppression') !== 'false');
const [closeToTray, setCloseToTray] = useState(localStorage.getItem('pear_close_to_tray') !== 'false');
const [pinMembers, setPinMembers] = useState(localStorage.getItem('pear_pin_members') === 'true');
const [notifyDMs, setNotifyDMs] = useState(localStorage.getItem('pear_notify_dms') !== 'false');
const [notifyHubs, setNotifyHubs] = useState(localStorage.getItem('pear_notify_hubs') !== 'false');
const [notifyMentions, setNotifyMentions] = useState(localStorage.getItem('pear_notify_mentions') !== 'false');
const [notifyCalls, setNotifyCalls] = useState(localStorage.getItem('pear_notify_calls') !== 'false');
const defaultTheme = {
base: '#000000',
@ -171,11 +196,6 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
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);
@ -240,7 +260,7 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
if (!finalUsername) return alert("Invalid username. Use only letters, numbers, underscores, and periods.");
}
onSave(tempName.trim(), tempAvatar, finalUsername);
onSave(tempName.trim(), tempAvatar, finalUsername, tempBio.trim(), profile.connections || []);
};
const copySeed = () => {
@ -253,6 +273,15 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
}
};
const handleTrayToggle = (e) => {
const val = e.target.checked;
setCloseToTray(val);
localStorage.setItem('pear_close_to_tray', val);
if (typeof window !== 'undefined' && window.require) {
window.require('electron').ipcRenderer.send('set-tray-setting', val);
}
};
return (
<div className="absolute inset-0 z-50 flex bg-base">
{/* Sidebar */}
@ -271,6 +300,18 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
>
Appearance
</button>
<button
onClick={() => setActiveTab('chat')}
className={`text-left px-3 py-1.5 rounded text-sm font-medium ${activeTab === 'chat' ? 'bg-panel text-text' : 'text-muted hover:bg-panel/50 hover:text-text'}`}
>
Chat Settings
</button>
<button
onClick={() => setActiveTab('notifications')}
className={`text-left px-3 py-1.5 rounded text-sm font-medium ${activeTab === 'notifications' ? 'bg-panel text-text' : 'text-muted hover:bg-panel/50 hover:text-text'}`}
>
Notifications
</button>
<button
onClick={() => setActiveTab('voice')}
className={`text-left px-3 py-1.5 rounded text-sm font-medium ${activeTab === 'voice' ? 'bg-panel text-text' : 'text-muted hover:bg-panel/50 hover:text-text'}`}
@ -315,7 +356,7 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
<h2 className="text-xl font-bold text-text mb-6">My Account</h2>
<div className="bg-surface rounded-lg p-4 mb-6">
<div className="flex items-center gap-6">
<div className="flex items-start gap-6">
<div
className={`w-24 h-24 rounded-md flex items-center justify-center text-white text-3xl font-bold cursor-pointer relative group overflow-hidden shrink-0 ${tempAvatar ? 'bg-transparent' : 'bg-indigo-500'}`}
onClick={() => fileInputRef.current?.click()}
@ -353,7 +394,7 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
type="text"
value={tempUsername}
onChange={(e) => setTempUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_.]/g, ''))}
className="w-full bg-panel text-text rounded p-2 outline-none focus:ring-1 focus:ring-accent text-sm font-mono"
className="w-full bg-panel text-text rounded p-2 outline-none focus:ring-1 focus:ring-accent text-sm font-mono mb-4"
placeholder="Set your username..."
maxLength={24}
/>
@ -362,9 +403,18 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
type="text"
value={'@' + profile.username}
readOnly
className="w-full bg-panel text-muted rounded p-2 outline-none text-sm font-mono cursor-not-allowed"
className="w-full bg-panel text-muted rounded p-2 outline-none text-sm font-mono cursor-not-allowed mb-4"
/>
)}
<label className="block text-xs font-bold text-muted uppercase mb-2">About Me (Bio)</label>
<textarea
value={tempBio}
onChange={(e) => setTempBio(e.target.value)}
className="w-full bg-panel text-text rounded p-2 outline-none focus:ring-1 focus:ring-accent text-sm resize-none h-24 custom-scrollbar"
placeholder="Tell people a little about yourself..."
maxLength={190}
/>
</div>
</div>
</div>
@ -405,6 +455,26 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
<div className="max-w-2xl">
<h2 className="text-xl font-bold text-text mb-6">Appearance</h2>
<div className="bg-surface rounded-lg p-6 mb-6">
<h3 className="text-muted uppercase text-xs font-bold mb-4">Layout</h3>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={pinMembers}
onChange={(e) => {
setPinMembers(e.target.checked);
localStorage.setItem('pear_pin_members', e.target.checked);
window.dispatchEvent(new Event('storage'));
}}
className="w-5 h-5 accent-accent cursor-pointer"
/>
<span className="text-sm text-text">Pin Online Users List</span>
</div>
<p className="text-[10px] text-muted mt-1 ml-8">
Keeps the members list permanently open on the right side of Hubs and Group Whispers.
</p>
</div>
<div className="bg-surface rounded-lg p-6 mb-6">
<h3 className="text-muted uppercase text-xs font-bold mb-4">Theme Colors</h3>
<div className="grid grid-cols-2 gap-4">
@ -430,6 +500,128 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
</div>
)}
{activeTab === 'chat' && (
<div className="max-w-2xl">
<h2 className="text-xl font-bold text-text mb-6">Chat Settings</h2>
<div className="bg-surface rounded-lg p-6 mb-6">
<h3 className="text-muted uppercase text-xs font-bold mb-4">Chat Appearance</h3>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={ircMode}
onChange={(e) => {
setIrcMode(e.target.checked);
localStorage.setItem('pear_irc_mode', e.target.checked);
window.dispatchEvent(new Event('storage'));
}}
className="w-5 h-5 accent-accent cursor-pointer"
/>
<span className="text-sm text-text">IRC Mode (Condensed chat)</span>
</div>
<p className="text-[10px] text-muted mt-1 ml-8">
Condenses chat to just lines with name, time, and message. Removes profile pictures.
</p>
</div>
<div className="bg-surface rounded-lg p-6 mb-6">
<h3 className="text-muted uppercase text-xs font-bold mb-4">Direct Messages</h3>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={liveDecryption}
onChange={(e) => {
setLiveDecryption(e.target.checked);
localStorage.setItem('pear_live_decryption', e.target.checked);
window.dispatchEvent(new Event('storage'));
}}
className="w-5 h-5 accent-accent cursor-pointer"
/>
<span className="text-sm text-text">Enable Live Decryption Animation</span>
</div>
<p className="text-[10px] text-muted mt-1 ml-8">
Visually animates the decryption of incoming end-to-end encrypted messages in real-time.
</p>
</div>
</div>
)}
{activeTab === 'notifications' && (
<div className="max-w-2xl">
<h2 className="text-xl font-bold text-text mb-6">Notifications</h2>
<div className="bg-surface rounded-lg p-6 mb-6">
<h3 className="text-muted uppercase text-xs font-bold mb-4">Desktop Notifications</h3>
<div className="space-y-4">
<div>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={notifyDMs}
onChange={(e) => {
setNotifyDMs(e.target.checked);
localStorage.setItem('pear_notify_dms', e.target.checked);
}}
className="w-5 h-5 accent-accent cursor-pointer"
/>
<span className="text-sm text-text">Direct Messages & Group Whispers</span>
</div>
<p className="text-[10px] text-muted mt-1 ml-8">Get notified when someone sends you a direct message.</p>
</div>
<div>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={notifyMentions}
onChange={(e) => {
setNotifyMentions(e.target.checked);
localStorage.setItem('pear_notify_mentions', e.target.checked);
}}
className="w-5 h-5 accent-accent cursor-pointer"
/>
<span className="text-sm text-text">Mentions</span>
</div>
<p className="text-[10px] text-muted mt-1 ml-8">Get notified when someone mentions you or @everyone in a Hub.</p>
</div>
<div>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={notifyHubs}
onChange={(e) => {
setNotifyHubs(e.target.checked);
localStorage.setItem('pear_notify_hubs', e.target.checked);
}}
className="w-5 h-5 accent-accent cursor-pointer"
/>
<span className="text-sm text-text">All Hub Messages</span>
</div>
<p className="text-[10px] text-muted mt-1 ml-8">Get notified for every single message sent in any Hub you are a part of.</p>
</div>
<div>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={notifyCalls}
onChange={(e) => {
setNotifyCalls(e.target.checked);
localStorage.setItem('pear_notify_calls', e.target.checked);
}}
className="w-5 h-5 accent-accent cursor-pointer"
/>
<span className="text-sm text-text">Incoming Calls</span>
</div>
<p className="text-[10px] text-muted mt-1 ml-8">Play a ringtone and show a popup when someone calls you.</p>
</div>
</div>
</div>
</div>
)}
{activeTab === 'voice' && (
<div className="max-w-2xl">
<h2 className="text-xl font-bold text-text mb-6">Voice & Video Settings</h2>
@ -467,7 +659,7 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
</select>
</div>
<div>
<div className="mb-6">
<label className="block text-xs font-bold text-muted uppercase mb-2">Camera (Webcam)</label>
<select
value={selectedVideoInput}
@ -482,6 +674,25 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
))}
</select>
</div>
<div>
<label className="block text-xs font-bold text-muted uppercase mb-2">Audio Processing</label>
<div className="flex items-center gap-3 bg-panel p-3 rounded">
<input
type="checkbox"
checked={noiseSuppression}
onChange={(e) => {
setNoiseSuppression(e.target.checked);
localStorage.setItem('pear_noise_suppression', e.target.checked);
}}
className="w-5 h-5 accent-accent cursor-pointer"
/>
<span className="text-sm text-text">Enable Noise Suppression (Crisp/NoiseTorch equivalent)</span>
</div>
<p className="text-[10px] text-muted mt-1 ml-8">
Uses advanced WebRTC audio processing to filter out background noise, keyboard clicks, and echo.
</p>
</div>
</div>
</div>
)}
@ -495,21 +706,18 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
<h2 className="text-xl font-bold text-text mb-6">App Settings</h2>
<div className="bg-surface rounded-lg p-6 mb-6">
<h3 className="text-muted uppercase text-xs font-bold mb-4">Direct Messages</h3>
<h3 className="text-muted uppercase text-xs font-bold mb-4">Window Behavior</h3>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={liveDecryption}
onChange={(e) => {
setLiveDecryption(e.target.checked);
localStorage.setItem('pear_live_decryption', e.target.checked);
}}
checked={closeToTray}
onChange={handleTrayToggle}
className="w-5 h-5 accent-accent cursor-pointer"
/>
<span className="text-sm text-text">Enable Live Decryption Animation</span>
<span className="text-sm text-text">Close button hides to System Tray</span>
</div>
<p className="text-[10px] text-muted mt-1 ml-8">
Visually animates the decryption of incoming end-to-end encrypted messages in real-time.
If disabled, clicking the X will completely quit the application.
</p>
</div>
@ -555,7 +763,10 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
<input
type="checkbox"
checked={autoRestart}
onChange={handleAutoRestartToggle}
onChange={(e) => {
setAutoRestart(e.target.checked);
localStorage.setItem('pear_auto_restart', e.target.checked);
}}
className="w-5 h-5 accent-accent cursor-pointer"
/>
<span className="text-sm text-text">Automatically restart to apply updates</span>

View File

@ -1,12 +1,42 @@
import React, { useState, useRef } from 'react';
import { network } from '../p2p/index.js';
import { network, ADMIN_PUBLIC_KEY } from '../p2p/index.js';
export default function ServerSettingsModal({ onClose, activeServerObj, onDeleteServer }) {
export default function ServerSettingsModal({ onClose, activeServerObj, myKey, onDeleteServer }) {
const [activeTab, setActiveTab] = useState('overview');
const [serverName, setServerName] = useState(activeServerObj.name || '');
const [serverIcon, setServerIcon] = useState(activeServerObj.icon || null);
const[allowAnyone, setAllowAnyone] = useState(activeServerObj.allowAnyoneToInvite);
const [allowAnyone, setAllowAnyone] = useState(activeServerObj.allowAnyoneToInvite);
const [channels, setChannels] = useState(activeServerObj.channels || { text: ['general-chat'], voice: ['general-voice'], permissions: {}, send_permissions: {} });
const [roles, setRoles] = useState(activeServerObj.roles || []);
const [memberRoles, setMemberRoles] = useState(activeServerObj.memberRoles || {});
const [editingRole, setEditingRole] = useState(null);
const [editingChannel, setEditingChannel] = useState(null);
const fileInputRef = useRef(null);
const serverMembers = network.serverMembers[activeServerObj.topicHex] ? Array.from(network.serverMembers[activeServerObj.topicHex]) : [];
if (!serverMembers.includes(activeServerObj.owner)) serverMembers.push(activeServerObj.owner);
const userRoles = activeServerObj.memberRoles?.[myKey] || [];
const isServerAdmin = activeServerObj.owner === myKey || myKey === ADMIN_PUBLIC_KEY || userRoles.some(rId => {
const r = activeServerObj.roles?.find(role => role.id === rId);
return r && r.permissions.includes('admin');
});
const canManageRoles = isServerAdmin || userRoles.some(rId => {
const r = activeServerObj.roles?.find(role => role.id === rId);
return r && r.permissions.includes('manage_roles');
});
const canManageChannels = isServerAdmin || userRoles.some(rId => {
const r = activeServerObj.roles?.find(role => role.id === rId);
return r && r.permissions.includes('manage_channels');
});
const canKickMembers = isServerAdmin || userRoles.some(rId => {
const r = activeServerObj.roles?.find(role => role.id === rId);
return r && r.permissions.includes('kick_members');
});
const handleImageUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
@ -41,7 +71,7 @@ export default function ServerSettingsModal({ onClose, activeServerObj, onDelete
const handleSave = () => {
if (serverName.trim() === '') return;
network.updateServerSettings(activeServerObj.topicHex, serverName.trim(), serverIcon, allowAnyone);
network.updateServerSettings(activeServerObj.topicHex, serverName.trim(), serverIcon, allowAnyone, channels, roles, memberRoles);
onClose();
};
@ -51,73 +81,514 @@ export default function ServerSettingsModal({ onClose, activeServerObj, onDelete
}
};
const createRole = () => {
const newRole = {
id: 'role_' + Date.now(),
name: 'New Role',
color: '#9ca3af',
permissions: ['send_messages', 'read_messages']
};
setRoles([...roles, newRole]);
setEditingRole(newRole);
};
const updateEditingRole = (key, value) => {
setEditingRole({ ...editingRole, [key]: value });
};
const togglePermission = (perm) => {
const perms = new Set(editingRole.permissions);
if (perms.has(perm)) perms.delete(perm);
else perms.add(perm);
updateEditingRole('permissions', Array.from(perms));
};
const saveRole = () => {
setRoles(roles.map(r => r.id === editingRole.id ? editingRole : r));
setEditingRole(null);
};
const deleteRole = (roleId) => {
setRoles(roles.filter(r => r.id !== roleId));
const newMemberRoles = { ...memberRoles };
for (const member in newMemberRoles) {
newMemberRoles[member] = newMemberRoles[member].filter(id => id !== roleId);
}
setMemberRoles(newMemberRoles);
// Remove role from channel permissions
const newChannels = { ...channels, permissions: { ...channels.permissions }, send_permissions: { ...channels.send_permissions } };
for (const ch in newChannels.permissions) {
newChannels.permissions[ch] = newChannels.permissions[ch].filter(id => id !== roleId);
}
for (const ch in newChannels.send_permissions) {
newChannels.send_permissions[ch] = newChannels.send_permissions[ch].filter(id => id !== roleId);
}
setChannels(newChannels);
};
const toggleMemberRole = (memberKey, roleId) => {
const currentRoles = memberRoles[memberKey] || [];
const newRoles = currentRoles.includes(roleId)
? currentRoles.filter(id => id !== roleId)
: [...currentRoles, roleId];
setMemberRoles({ ...memberRoles, [memberKey]: newRoles });
};
const toggleChannelRole = (channelName, roleId) => {
const perms = channels.permissions || {};
const currentRoles = perms[channelName] || [];
const newRoles = currentRoles.includes(roleId)
? currentRoles.filter(id => id !== roleId)
: [...currentRoles, roleId];
setChannels({
...channels,
permissions: {
...perms,
[channelName]: newRoles
}
});
};
const toggleChannelSendRole = (channelName, roleId) => {
const perms = channels.send_permissions || {};
const currentRoles = perms[channelName] || [];
const newRoles = currentRoles.includes(roleId)
? currentRoles.filter(id => id !== roleId)
: [...currentRoles, roleId];
setChannels({
...channels,
send_permissions: {
...perms,
[channelName]: newRoles
}
});
};
const handleKickMember = (memberKey) => {
if (window.confirm("Are you sure you want to kick this member?")) {
network._appendSignedMessage({ type: 'server_leave', serverTopicHex: activeServerObj.topicHex, targetUser: memberKey, timestamp: Date.now() });
if (network.serverMembers[activeServerObj.topicHex]) {
network.serverMembers[activeServerObj.topicHex].delete(memberKey);
network._emitServerMembers();
}
}
};
return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/70" onClick={onClose}>
<div className="bg-surface rounded-lg shadow-xl w-full max-w-md flex flex-col p-6 max-h-[90vh] overflow-y-auto border border-panel" onClick={e => e.stopPropagation()}>
<h2 className="text-2xl font-bold text-center text-text mb-2">Hub Settings</h2>
<div className="bg-surface rounded-lg shadow-xl w-full max-w-3xl flex flex-col h-[80vh] border border-panel overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="flex flex-col items-center gap-4 mt-2">
<div
className={`w-24 h-24 rounded-md flex items-center justify-center text-white text-3xl font-bold cursor-pointer relative group overflow-hidden shrink-0 border-2 border-dashed border-muted hover:border-text ${serverIcon ? 'bg-transparent border-solid' : 'bg-panel'}`}
onClick={() => fileInputRef.current?.click()}
>
{serverIcon ? (
<img src={serverIcon} alt="hub icon" className="w-full h-full object-cover" />
) : (
<div className="text-center text-xs text-muted flex flex-col items-center gap-1">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"/></svg>
Change
</div>
<div className="flex h-full">
{/* Sidebar */}
<div className="w-48 bg-panel flex flex-col py-6 px-3 border-r border-surface shrink-0">
<h2 className="text-sm font-bold text-text mb-4 px-2 truncate">{activeServerObj.name}</h2>
<button
onClick={() => { setActiveTab('overview'); setEditingChannel(null); setEditingRole(null); }}
className={`text-left px-3 py-2 rounded text-sm font-medium mb-1 ${activeTab === 'overview' ? 'bg-surface text-text' : 'text-muted hover:bg-surface/50 hover:text-text'}`}
>
Overview
</button>
{canManageRoles && (
<button
onClick={() => { setActiveTab('roles'); setEditingChannel(null); }}
className={`text-left px-3 py-2 rounded text-sm font-medium mb-1 ${activeTab === 'roles' ? 'bg-surface text-text' : 'text-muted hover:bg-surface/50 hover:text-text'}`}
>
Roles
</button>
)}
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[10px] uppercase tracking-wider text-white">Upload</span>
</div>
<input type="file" ref={fileInputRef} onChange={handleImageUpload} accept="image/png, image/jpeg" className="hidden" />
{canManageChannels && (
<button
onClick={() => { setActiveTab('channels'); setEditingRole(null); }}
className={`text-left px-3 py-2 rounded text-sm font-medium mb-1 ${activeTab === 'channels' ? 'bg-surface text-text' : 'text-muted hover:bg-surface/50 hover:text-text'}`}
>
Channels
</button>
)}
<button
onClick={() => { setActiveTab('members'); setEditingChannel(null); setEditingRole(null); }}
className={`text-left px-3 py-2 rounded text-sm font-medium mb-1 ${activeTab === 'members' ? 'bg-surface text-text' : 'text-muted hover:bg-surface/50 hover:text-text'}`}
>
Members
</button>
</div>
<div className="w-full">
<label className="block text-xs font-bold text-muted uppercase mb-2 text-left">Hub Name</label>
<input
type="text"
value={serverName}
onChange={(e) => 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}
/>
{/* Content */}
<div className="flex-1 flex flex-col relative overflow-hidden">
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
{activeTab === 'overview' && (
<div className="flex flex-col gap-6">
<h3 className="text-xl font-bold text-text">Server Overview</h3>
<div className="flex items-start gap-6">
<div
className={`w-24 h-24 rounded-md flex items-center justify-center text-white text-3xl font-bold ${isServerAdmin ? 'cursor-pointer hover:border-text' : 'cursor-default'} relative group overflow-hidden shrink-0 border-2 border-dashed border-muted ${serverIcon ? 'bg-transparent border-solid' : 'bg-panel'}`}
onClick={() => isServerAdmin && fileInputRef.current?.click()}
>
{serverIcon ? (
<img src={serverIcon} alt="hub icon" className="w-full h-full object-cover" />
) : (
<div className="text-center text-xs text-muted flex flex-col items-center gap-1">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"/></svg>
{isServerAdmin ? 'Upload' : 'No Icon'}
</div>
)}
{isServerAdmin && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[10px] uppercase tracking-wider text-white">Upload</span>
</div>
)}
<input type="file" ref={fileInputRef} onChange={handleImageUpload} accept="image/png, image/jpeg" className="hidden" />
</div>
<div className="flex-1">
<label className="block text-xs font-bold text-muted uppercase mb-2">Hub Name</label>
<input
type="text"
value={serverName}
onChange={(e) => setServerName(e.target.value)}
disabled={!isServerAdmin}
className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-2 focus:ring-accent mb-4 disabled:opacity-50"
maxLength={32}
/>
<label className="block text-xs font-bold text-muted uppercase mb-2">Invite Permissions</label>
<div className="flex items-center gap-3 bg-panel p-3 rounded">
<input
type="checkbox"
checked={allowAnyone}
onChange={(e) => setAllowAnyone(e.target.checked)}
disabled={!isServerAdmin}
className="w-5 h-5 accent-accent cursor-pointer disabled:opacity-50"
/>
<span className="text-sm text-text">Anyone can invite people to this hub</span>
</div>
<p className="text-[10px] text-muted mt-1">If unchecked, only Admins can send invites.</p>
</div>
</div>
{isServerAdmin && (
<div className="bg-panel rounded p-4 border border-red-900/50 mt-4">
<h3 className="text-red-500 font-bold mb-2 uppercase text-xs">Danger Zone</h3>
<button
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors"
>
Delete Hub
</button>
</div>
)}
</div>
)}
{activeTab === 'roles' && canManageRoles && (
<div className="flex flex-col h-full">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-bold text-text">Roles</h3>
{!editingRole && (
<button onClick={createRole} className="bg-accent hover:opacity-90 text-white px-3 py-1.5 rounded text-sm font-medium transition-opacity">
Create Role
</button>
)}
</div>
{editingRole ? (
<div className="flex flex-col gap-4 bg-panel p-4 rounded border border-surface">
<div className="flex justify-between items-center mb-2">
<h4 className="font-bold text-text">Edit Role</h4>
<button onClick={() => setEditingRole(null)} className="text-muted hover:text-text text-sm">Cancel</button>
</div>
<div className="flex gap-4">
<div className="flex-1">
<label className="block text-xs font-bold text-muted uppercase mb-2">Role Name</label>
<input
type="text"
value={editingRole.name}
onChange={(e) => updateEditingRole('name', e.target.value)}
className="w-full bg-base text-text rounded p-2 outline-none focus:ring-1 focus:ring-accent text-sm"
/>
</div>
<div>
<label className="block text-xs font-bold text-muted uppercase mb-2">Role Color</label>
<input
type="color"
value={editingRole.color}
onChange={(e) => updateEditingRole('color', e.target.value)}
className="w-10 h-10 rounded cursor-pointer bg-transparent border-none p-0"
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-muted uppercase mb-2 mt-2">Permissions</label>
<div className="space-y-2 bg-base p-3 rounded">
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={editingRole.permissions.includes('admin')} onChange={() => togglePermission('admin')} className="w-4 h-4 accent-accent" />
<span className="text-sm text-text">Administrator (Full Access)</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={editingRole.permissions.includes('send_messages')} onChange={() => togglePermission('send_messages')} className="w-4 h-4 accent-accent" />
<span className="text-sm text-text">Send Messages</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={editingRole.permissions.includes('read_messages')} onChange={() => togglePermission('read_messages')} className="w-4 h-4 accent-accent" />
<span className="text-sm text-text">Read Messages</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={editingRole.permissions.includes('manage_channels')} onChange={() => togglePermission('manage_channels')} className="w-4 h-4 accent-accent" />
<span className="text-sm text-text">Manage Channels</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={editingRole.permissions.includes('manage_roles')} onChange={() => togglePermission('manage_roles')} className="w-4 h-4 accent-accent" />
<span className="text-sm text-text">Manage Roles</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={editingRole.permissions.includes('kick_members')} onChange={() => togglePermission('kick_members')} className="w-4 h-4 accent-accent" />
<span className="text-sm text-text">Kick Members</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={editingRole.permissions.includes('send_files')} onChange={() => togglePermission('send_files')} className="w-4 h-4 accent-accent" />
<span className="text-sm text-text">Send Files</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={editingRole.permissions.includes('add_reactions')} onChange={() => togglePermission('add_reactions')} className="w-4 h-4 accent-accent" />
<span className="text-sm text-text">Add Reactions</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={editingRole.permissions.includes('mention_everyone')} onChange={() => togglePermission('mention_everyone')} className="w-4 h-4 accent-accent" />
<span className="text-sm text-text">Mention Everyone</span>
</label>
</div>
</div>
<div className="flex justify-end gap-2 mt-2">
<button onClick={saveRole} className="bg-green-600 hover:bg-green-700 text-white px-4 py-1.5 rounded text-sm font-medium transition-colors">
Save Role
</button>
</div>
</div>
) : (
<div className="space-y-2">
{roles.map(role => (
<div key={role.id} className="flex items-center justify-between bg-panel p-3 rounded border border-surface group">
<div className="flex items-center gap-3">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: role.color }}></div>
<span className="text-sm font-bold text-text">{role.name}</span>
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => setEditingRole(role)} className="text-muted hover:text-text text-sm px-2">Edit</button>
<button onClick={() => deleteRole(role.id)} className="text-red-500 hover:text-red-400 text-sm px-2">Delete</button>
</div>
</div>
))}
{roles.length === 0 && <div className="text-muted text-sm text-center py-4">No roles created yet.</div>}
</div>
)}
</div>
)}
{activeTab === 'channels' && canManageChannels && (
<div className="flex flex-col h-full">
<h3 className="text-xl font-bold text-text mb-6">Channels</h3>
{editingChannel ? (
<div className="flex flex-col gap-4 bg-panel p-4 rounded border border-surface">
<div className="flex justify-between items-center mb-2">
<h4 className="font-bold text-text flex items-center gap-2">
<span className="text-muted">{editingChannel.type === 'text' ? '#' : '🔊'}</span>
{editingChannel.name} Permissions
</h4>
<button onClick={() => setEditingChannel(null)} className="text-muted hover:text-text text-sm">Back</button>
</div>
<div className="space-y-4">
<div>
<h5 className="text-sm font-bold text-text mb-1">Who can access this channel?</h5>
<p className="text-xs text-muted mb-2">If no roles are selected, the channel is public to all members. Admins always have access.</p>
<div className="space-y-2 bg-base p-3 rounded">
{roles.map(role => {
const isChecked = (channels.permissions?.[editingChannel.name] || []).includes(role.id);
return (
<label key={role.id} className="flex items-center gap-3 cursor-pointer p-1 hover:bg-panel rounded">
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleChannelRole(editingChannel.name, role.id)}
className="w-4 h-4 accent-accent"
/>
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: role.color }}></div>
<span className="text-sm text-text">{role.name}</span>
</label>
);
})}
{roles.length === 0 && <span className="text-xs text-muted">No roles created yet. Go to the Roles tab to create some.</span>}
</div>
</div>
{editingChannel.type === 'text' && (
<div>
<h5 className="text-sm font-bold text-text mb-1">Who can send messages?</h5>
<p className="text-xs text-muted mb-2">If no roles are selected, anyone with access can send messages (based on their global role).</p>
<div className="space-y-2 bg-base p-3 rounded">
{roles.map(role => {
const isChecked = (channels.send_permissions?.[editingChannel.name] || []).includes(role.id);
return (
<label key={role.id} className="flex items-center gap-3 cursor-pointer p-1 hover:bg-panel rounded">
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleChannelSendRole(editingChannel.name, role.id)}
className="w-4 h-4 accent-accent"
/>
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: role.color }}></div>
<span className="text-sm text-text">{role.name}</span>
</label>
);
})}
{roles.length === 0 && <span className="text-xs text-muted">No roles created yet.</span>}
</div>
</div>
)}
</div>
</div>
) : (
<div className="space-y-6">
<div>
<h4 className="text-xs font-bold text-muted uppercase mb-2">Text Channels</h4>
<div className="space-y-1">
{channels.text.map(ch => {
const restrictedCount = (channels.permissions?.[ch] || []).length;
const sendRestrictedCount = (channels.send_permissions?.[ch] || []).length;
return (
<div key={ch} className="flex items-center justify-between bg-panel p-2 rounded border border-surface group">
<div className="flex items-center gap-2">
<span className="text-muted">#</span>
<span className="text-sm font-medium text-text">{ch}</span>
{restrictedCount > 0 && <span className="text-[10px] bg-base px-1.5 py-0.5 rounded text-muted ml-2">Private ({restrictedCount} roles)</span>}
{sendRestrictedCount > 0 && <span className="text-[10px] bg-base px-1.5 py-0.5 rounded text-muted ml-2">Read-Only ({sendRestrictedCount} roles)</span>}
</div>
<button onClick={() => setEditingChannel({ name: ch, type: 'text' })} className="text-muted hover:text-text text-xs px-2 opacity-0 group-hover:opacity-100 transition-opacity">
Edit Permissions
</button>
</div>
);
})}
</div>
</div>
<div>
<h4 className="text-xs font-bold text-muted uppercase mb-2">Voice Channels</h4>
<div className="space-y-1">
{channels.voice.map(ch => {
const restrictedCount = (channels.permissions?.[ch] || []).length;
return (
<div key={ch} className="flex items-center justify-between bg-panel p-2 rounded border border-surface group">
<div className="flex items-center gap-2">
<span className="text-muted">🔊</span>
<span className="text-sm font-medium text-text">{ch}</span>
{restrictedCount > 0 && <span className="text-[10px] bg-base px-1.5 py-0.5 rounded text-muted ml-2">Private ({restrictedCount} roles)</span>}
</div>
<button onClick={() => setEditingChannel({ name: ch, type: 'voice' })} className="text-muted hover:text-text text-xs px-2 opacity-0 group-hover:opacity-100 transition-opacity">
Edit Permissions
</button>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
)}
{activeTab === 'members' && (
<div className="flex flex-col h-full">
<h3 className="text-xl font-bold text-text mb-6">Members</h3>
<div className="space-y-2">
{serverMembers.map(memberKey => {
const profile = network.knownProfiles.get(memberKey) || { displayName: 'Unknown User' };
const userRoles = memberRoles[memberKey] || [];
return (
<div key={memberKey} className="flex flex-col bg-panel p-3 rounded border border-surface gap-2">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-md flex items-center justify-center text-white text-xs font-bold overflow-hidden ${profile.avatar ? 'bg-transparent' : 'bg-indigo-500'}`}>
{profile.avatar ? <img src={profile.avatar} className="w-full h-full object-cover" /> : profile.displayName.substring(0, 2).toUpperCase()}
</div>
<span className="text-sm font-bold text-text">{profile.displayName} {memberKey === activeServerObj.owner && <span className="text-yellow-500 ml-1" title="Owner">👑</span>}</span>
</div>
<div className="flex flex-wrap gap-2 mt-1">
{userRoles.map(rId => {
const role = roles.find(r => r.id === rId);
if (!role) return null;
return (
<div key={rId} className="flex items-center gap-1.5 bg-base px-2 py-1 rounded border border-surface">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: role.color }}></div>
<span className="text-xs text-text">{role.name}</span>
{canManageRoles && (
<button onClick={() => toggleMemberRole(memberKey, rId)} className="text-muted hover:text-red-500 ml-1">×</button>
)}
</div>
);
})}
{canManageRoles && (
<div className="relative group/addrole">
<button className="flex items-center justify-center w-6 h-6 rounded bg-base border border-surface text-muted hover:text-text hover:border-muted transition-colors">
+
</button>
<div className="absolute left-0 top-full mt-1 bg-base border border-surface rounded shadow-xl p-1 hidden group-hover/addrole:flex flex-col w-32 z-10">
{roles.map(role => (
<button
key={role.id}
onClick={() => toggleMemberRole(memberKey, role.id)}
className="text-left px-2 py-1.5 text-xs text-text hover:bg-panel rounded flex items-center gap-2"
>
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: role.color }}></div>
{role.name}
{userRoles.includes(role.id) && <span className="ml-auto text-green-500"></span>}
</button>
))}
{roles.length === 0 && <span className="text-xs text-muted p-2">No roles available</span>}
</div>
</div>
)}
{canKickMembers && memberKey !== activeServerObj.owner && (
<button
onClick={() => handleKickMember(memberKey)}
className="ml-auto text-xs text-red-500 hover:text-red-400 px-2 py-1 rounded border border-red-500/30 hover:bg-red-500/10 transition-colors"
>
Kick
</button>
)}
</div>
</div>
);
})}
</div>
</div>
)}
<label className="block text-xs font-bold text-muted uppercase mb-2 text-left">Invite Permissions</label>
<div className="flex items-center gap-3 bg-panel p-3 rounded">
<input
type="checkbox"
checked={allowAnyone}
onChange={(e) => setAllowAnyone(e.target.checked)}
className="w-5 h-5 accent-accent cursor-pointer"
/>
<span className="text-sm text-text">Anyone can invite people to this hub</span>
</div>
<p className="text-[10px] text-muted mt-1 mb-4">If unchecked, only you (the Admin) can send invites.</p>
<div className="bg-panel rounded p-4 border border-red-900/50 mt-2">
<h3 className="text-red-500 font-bold mb-2 uppercase text-xs">Danger Zone</h3>
<button
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors w-full"
>
Delete Hub
<div className="p-4 bg-base flex justify-end gap-3 border-t border-surface shrink-0">
<button onClick={onClose} className="text-text hover:underline text-sm font-medium px-4 py-2">
Cancel
</button>
<button onClick={handleSave} disabled={!serverName.trim()} className="bg-accent hover:opacity-90 text-white px-6 py-2.5 rounded text-sm font-medium transition-opacity disabled:opacity-50 disabled:cursor-not-allowed">
Save Changes
</button>
</div>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 mt-2">
<button onClick={onClose} className="text-text hover:underline text-sm font-medium px-4 py-2">
Cancel
</button>
<button onClick={handleSave} disabled={!serverName.trim()} className="bg-accent hover:opacity-90 text-white px-6 py-2.5 rounded text-sm font-medium transition-opacity disabled:opacity-50 disabled:cursor-not-allowed">
Save Changes
</button>
</div>
</div>
</div>
);

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { generateIdentitySeed, network } from '../p2p/index.js';
import logo from '../../assets/icon.png';
@ -7,9 +7,10 @@ export default function SetupScreen({ setProfile }) {
const [savedAccounts, setSavedAccounts] = useState([]);
const [displayName, setDisplayName] = useState('');
const[username, setUsername] = useState('');
const [username, setUsername] = useState('');
const [seedHex, setSeedHex] = useState('');
const [isChecking, setIsChecking] = useState(false);
const [seedAcknowledged, setSeedAcknowledged] = useState(false);
useEffect(() => {
const accounts = JSON.parse(localStorage.getItem('pear_saved_accounts') || '[]');
@ -30,7 +31,7 @@ export default function SetupScreen({ setProfile }) {
const handleSignup = async (e) => {
e.preventDefault();
if (!displayName.trim() || !username.trim()) return;
if (!displayName.trim() || !username.trim() || !seedAcknowledged) return;
const cleanUsername = username.trim().toLowerCase().replace(/[^a-z0-9_.]/g, '');
if (!cleanUsername) return alert("Invalid username. Use only letters, numbers, underscores, and periods.");
@ -129,7 +130,7 @@ export default function SetupScreen({ setProfile }) {
disabled={isChecking}
/>
</div>
<div className="mb-6">
<div className="mb-4">
<label className="block text-xs font-bold text-muted uppercase mb-2">Username</label>
<input
type="text"
@ -141,7 +142,20 @@ export default function SetupScreen({ setProfile }) {
disabled={isChecking}
/>
</div>
<button type="submit" disabled={isChecking} className="w-full bg-accent hover:opacity-90 text-white font-bold py-3 rounded transition-opacity disabled:opacity-50 flex justify-center items-center gap-2">
<div className="mb-6 flex items-start gap-2 bg-red-500/10 p-3 rounded border border-red-500/30">
<input
type="checkbox"
checked={seedAcknowledged}
onChange={e => setSeedAcknowledged(e.target.checked)}
className="mt-1 accent-red-500 cursor-pointer shrink-0"
/>
<span className="text-xs text-red-400 leading-tight">
I understand that my Account Seed is the ONLY way to recover my account. If I lose it, I lose my account forever.
</span>
</div>
<button type="submit" disabled={isChecking || !seedAcknowledged} className="w-full bg-accent hover:opacity-90 text-white font-bold py-3 rounded transition-opacity disabled:opacity-50 flex justify-center items-center gap-2">
{isChecking ? (
<>
<span className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>

View File

@ -1,7 +1,68 @@
import React, { useState, useEffect } from 'react';
import logo from '../../assets/iconWhite.png';
export default function Sidebar({ activeView, setActiveView, servers, myKey, onOpenCreateServer, onLeaveServer }) {
// MOVED OUTSIDE: This prevents React from destroying and recreating the DOM node on every render,
// which was the actual cause of the severe flickering.
const NavItem = ({ id, icon, name, isImage, isServerImage, imageClass, onClick, onContextMenu, hasUnread, badgeCount, isActive }) => {
let dynamicClasses = "";
if (isServerImage) {
dynamicClasses = isActive
? "bg-panel rounded-[16px] ring-2 ring-white"
: "bg-panel rounded-[24px] hover:rounded-[16px] hover:ring-2 hover:ring-white/50";
} else {
dynamicClasses = isActive
? "bg-accent text-white rounded-[16px]"
: "bg-panel text-text rounded-[24px] hover:rounded-[16px] hover:bg-accent hover:text-white";
}
return (
<div className="relative flex justify-center w-full mb-2">
{/* The Interaction Target & Visual Shape */}
<div
className={`relative w-12 h-12 flex items-center justify-center font-bold transition-all duration-300 cursor-pointer group ${dynamicClasses}`}
onClick={onClick}
onContextMenu={onContextMenu}
>
{/* The Image/Icon */}
<div className="w-full h-full pointer-events-none flex items-center justify-center" style={{ borderRadius: 'inherit', overflow: 'hidden' }}>
{isImage ? (
<img src={icon} alt={name} className={`${imageClass || 'w-full h-full object-cover'}`} />
) : (
icon
)}
</div>
{/* The Active Indicator (White Pill) */}
<div className={`absolute -left-[12px] top-1/2 -translate-y-1/2 w-1 bg-text rounded-r-full transition-all duration-300 pointer-events-none ${
isActive ? 'h-10' : (hasUnread ? 'h-2' : 'h-0 group-hover:h-5')
}`}></div>
{/* The Tooltip */}
<div className="absolute left-[62px] top-1/2 -translate-y-1/2 flex items-center opacity-0 group-hover:opacity-100 pointer-events-none z-50 scale-95 group-hover:scale-100 transition-all origin-left duration-150">
<div className="w-0 h-0 border-y-[6px] border-y-transparent border-r-[6px] border-r-panel -mr-[1px]"></div>
<div className="bg-panel text-text text-[15px] font-bold py-1.5 px-3 rounded-md shadow-xl whitespace-nowrap">
{name}
</div>
</div>
{/* Red Dot Indicator (For Servers) */}
{hasUnread && !isActive && !badgeCount && (
<div className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 bg-red-500 rounded-full border-[3px] border-base pointer-events-none"></div>
)}
{/* Numbered Badge (For Whispers & Hubs) */}
{badgeCount > 0 && (
<div className={`absolute -bottom-1 -right-1 bg-red-500 text-white text-[10px] font-bold border-[3px] border-base pointer-events-none flex items-center justify-center shadow-sm rounded-full h-5 ${badgeCount > 9 ? 'px-1 min-w-[20px]' : 'w-5'}`}>
{badgeCount > 99 ? '99+' : badgeCount}
</div>
)}
</div>
</div>
);
};
export default function Sidebar({ activeView, setActiveView, servers, myKey, onOpenCreateServer, onLeaveServer, unreadCounts = {} }) {
const[contextMenu, setContextMenu] = useState(null);
useEffect(() => {
@ -12,37 +73,13 @@ export default function Sidebar({ activeView, setActiveView, servers, myKey, onO
const publicServers = servers.filter(s => s.isGroupChat !== true);
const NavItem = ({ id, icon, name, isImage, imageClass, onClick, onContextMenu }) => {
const isActive = activeView === id;
return (
<div className="relative group flex justify-center w-full mb-2">
<div className={`absolute left-0 top-1/2 -translate-y-1/2 w-1 bg-text rounded-r-full transition-all duration-300 ${isActive ? 'h-10' : 'h-0 group-hover:h-5'}`}></div>
<div
onClick={onClick}
onContextMenu={onContextMenu}
className={`w-12 h-12 flex items-center justify-center font-bold shrink-0 overflow-hidden transition-all duration-300 cursor-pointer ${
isActive
? 'bg-accent text-white rounded-[16px]'
: 'bg-panel text-text rounded-[24px] hover:rounded-[16px] hover:bg-accent hover:text-white'
}`}
>
{isImage ? (
<img src={icon} alt={name} className={`${imageClass || 'w-full h-full object-cover'} pointer-events-none`} />
) : (
icon
)}
</div>
{/* Discord-style Tooltip */}
<div className="absolute left-[74px] top-1/2 -translate-y-1/2 flex items-center opacity-0 group-hover:opacity-100 pointer-events-none z-50 scale-95 group-hover:scale-100 transition-all origin-left duration-150">
<div className="w-0 h-0 border-y-[6px] border-y-transparent border-r-[6px] border-r-panel -mr-[1px]"></div>
<div className="bg-panel text-text text-[15px] font-bold py-1.5 px-3 rounded-md shadow-xl whitespace-nowrap">
{name}
</div>
</div>
</div>
);
};
// Calculate total unread DMs and Group Chats for the Whispers badge
let dmUnreadCount = 0;
Object.entries(unreadCounts).forEach(([key, count]) => {
if (!key.includes('-') || key.length === 64) {
dmUnreadCount += count;
}
});
return (
<div className="w-[72px] bg-base flex flex-col py-3 items-center shrink-0 overflow-y-auto hide-scrollbar border-r border-surface relative z-20">
@ -71,42 +108,61 @@ export default function Sidebar({ activeView, setActiveView, servers, myKey, onO
id="dms"
name="Whispers"
isImage={true}
isServerImage={false}
icon={logo}
imageClass="w-7 h-7 object-contain"
onClick={() => setActiveView('dms')}
badgeCount={dmUnreadCount}
isActive={activeView === 'dms'}
/>
<div className="w-8 h-[2px] bg-surface rounded-full my-2 shrink-0"></div>
{publicServers.map(server => (
<NavItem
key={server.topicHex}
id={server.topicHex}
name={server.name}
isImage={!!server.icon}
icon={server.icon || server.name.substring(0, 2).toUpperCase()}
onClick={() => setActiveView(server.topicHex)}
onContextMenu={(e) => {
e.preventDefault();
if (server.owner === myKey) return;
setContextMenu({ x: e.pageX, y: e.pageY, topicHex: server.topicHex });
}}
/>
))}
{publicServers.map(server => {
let serverUnreadCount = 0;
Object.entries(unreadCounts).forEach(([key, count]) => {
if (key.startsWith(server.topicHex + '-')) {
serverUnreadCount += count;
}
});
<div
onClick={onOpenCreateServer}
className="relative group flex justify-center w-full mt-2"
>
<div className="w-12 h-12 flex items-center justify-center font-bold shrink-0 overflow-hidden transition-all duration-300 cursor-pointer bg-panel text-accent rounded-[24px] hover:rounded-[16px] hover:bg-accent hover:text-white">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
</div>
{/* Discord-style Tooltip */}
<div className="absolute left-[74px] top-1/2 -translate-y-1/2 flex items-center opacity-0 group-hover:opacity-100 pointer-events-none z-50 scale-95 group-hover:scale-100 transition-all origin-left duration-150">
<div className="w-0 h-0 border-y-[6px] border-y-transparent border-r-[6px] border-r-panel -mr-[1px]"></div>
<div className="bg-panel text-text text-[15px] font-bold py-1.5 px-3 rounded-md shadow-xl whitespace-nowrap">
Create Hub
return (
<NavItem
key={server.topicHex}
id={server.topicHex}
name={server.name}
isImage={!!server.icon}
isServerImage={!!server.icon}
icon={server.icon || server.name.substring(0, 2).toUpperCase()}
onClick={() => setActiveView(server.topicHex)}
hasUnread={serverUnreadCount > 0}
badgeCount={serverUnreadCount}
isActive={activeView === server.topicHex}
onContextMenu={(e) => {
e.preventDefault();
if (server.owner === myKey) return;
setContextMenu({ x: e.pageX, y: e.pageY, topicHex: server.topicHex });
}}
/>
);
})}
{/* Create Hub Button */}
<div className="relative flex justify-center w-full mt-2">
<div
className="relative w-12 h-12 flex items-center justify-center font-bold transition-all duration-300 cursor-pointer group bg-panel text-accent rounded-[24px] hover:rounded-[16px] hover:bg-accent hover:text-white"
onClick={onOpenCreateServer}
>
<div className="w-full h-full pointer-events-none flex items-center justify-center" style={{ borderRadius: 'inherit', overflow: 'hidden' }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
</div>
{/* Tooltip */}
<div className="absolute left-[62px] top-1/2 -translate-y-1/2 flex items-center opacity-0 group-hover:opacity-100 pointer-events-none z-50 scale-95 group-hover:scale-100 transition-all origin-left duration-150">
<div className="w-0 h-0 border-y-[6px] border-y-transparent border-r-[6px] border-r-panel -mr-[1px]"></div>
<div className="bg-panel text-text text-[15px] font-bold py-1.5 px-3 rounded-md shadow-xl whitespace-nowrap">
Create Hub
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,71 @@
import React from 'react';
export default function UserProfileModal({ user, onClose, onSendDM }) {
if (!user) return null;
return (
<div className="absolute inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={onClose}>
<div className="bg-surface rounded-xl shadow-2xl w-full max-w-sm flex flex-col overflow-hidden border border-panel" onClick={e => e.stopPropagation()}>
{/* Banner */}
<div className="h-24 bg-panel w-full relative border-b border-surface">
<button onClick={onClose} className="absolute top-3 right-3 w-8 h-8 bg-black/50 hover:bg-black/70 text-white rounded-full flex items-center justify-center transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
{/* Profile Info */}
<div className="px-6 pb-6 relative">
<div className={`absolute -top-12 left-6 w-24 h-24 rounded-xl border-4 border-surface flex items-center justify-center text-white text-4xl font-bold overflow-hidden shadow-lg ${user.avatar ? 'bg-surface' : 'bg-indigo-500'}`}>
{user.avatar ? (
<img src={user.avatar} alt="avatar" className="w-full h-full object-cover" />
) : (
user.displayName?.substring(0, 2).toUpperCase() || '?'
)}
</div>
<div className="mt-14 flex flex-col">
<h2 className="text-2xl font-bold text-text leading-tight">{user.displayName}</h2>
<span className="text-sm text-muted font-mono">@{user.username}</span>
</div>
<div className="w-full h-[1px] bg-panel my-4"></div>
<div className="flex flex-col gap-4">
<div>
<h3 className="text-xs font-bold text-muted uppercase mb-1">About Me</h3>
<p className="text-sm text-text whitespace-pre-wrap break-words leading-relaxed">
{user.bio || <span className="italic text-muted/50">This user hasn't written a bio yet.</span>}
</p>
</div>
{user.connections && user.connections.length > 0 && (
<div>
<h3 className="text-xs font-bold text-muted uppercase mb-2">Connections</h3>
<div className="flex flex-wrap gap-2">
{user.connections.map((conn, i) => (
<div key={i} className="bg-panel px-3 py-1.5 rounded flex items-center gap-2 border border-surface">
<span className="text-xs font-bold text-text">{conn.platform}:</span>
<span className="text-xs text-muted">{conn.username}</span>
</div>
))}
</div>
</div>
)}
</div>
{onSendDM && (
<button
onClick={() => { onSendDM(user); onClose(); }}
className="w-full mt-6 bg-accent hover:opacity-90 text-white font-bold py-2.5 rounded transition-opacity flex items-center justify-center gap-2"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
Send Message
</button>
)}
</div>
</div>
</div>
);
}

View File

@ -32,9 +32,18 @@ async function handleIdentity(network, peerKey, parsed) {
peerInfo.displayName = parsed.displayName;
peerInfo.username = parsed.username;
peerInfo.avatar = parsed.avatar;
peerInfo.bio = parsed.bio || '';
peerInfo.connections = parsed.connections || [];
peerInfo.coreKey = parsed.coreKey;
const profileObj = { displayName: parsed.displayName, username: parsed.username, avatar: parsed.avatar };
const profileObj = {
displayName: parsed.displayName,
username: parsed.username,
avatar: parsed.avatar,
bio: parsed.bio || '',
connections: parsed.connections || []
};
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);
@ -43,9 +52,9 @@ async function handleIdentity(network, peerKey, parsed) {
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);
network.userDirectory.set(uname, { pubKey: peerKey, profile: profileObj });
network.dirDb.put(uname, { pubKey: peerKey, profile: profileObj });
network._checkPendingRequests(uname, peerKey, profileObj);
}
if (network.dms[peerKey]) {

View File

@ -2,7 +2,7 @@ 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 { getAllMessages, processMessage, sendDMRequest, sendMessage, sendDM, sendEditMessage, sendDeleteMessage, acceptDMRequest, sendEphemeral, sendReadReceipt, sendDeliveredReceipt, sendOffline, sendTyping, sendReaction, _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';
@ -27,6 +27,8 @@ class P2PNetwork {
this.displayName = '';
this.username = '';
this.avatar = null;
this.bio = '';
this.connections = [];
this.storagePath = null;
this.peers = new Map();
@ -52,7 +54,7 @@ class P2PNetwork {
this.logicalClock = 0;
this.timeOffset = 0;
// App State Tracking (Used to prevent auto-restarts during critical operations)
// App State Tracking
this.activeCalls = 0;
this.onInit = null;
@ -72,10 +74,11 @@ class P2PNetwork {
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);
sendMessage = (channel, text, replyTo) => sendMessage(this, channel, text, replyTo);
sendDM = (targetKey, text, replyTo) => sendDM(this, targetKey, text, replyTo);
sendEditMessage = (targetId, newText) => sendEditMessage(this, targetId, newText);
sendDeleteMessage = (targetId) => sendDeleteMessage(this, targetId);
sendReaction = (targetId, emoji, isDM, targetKey) => sendReaction(this, targetId, emoji, isDM, targetKey);
sendEphemeral = (payload) => sendEphemeral(this, payload);
sendReadReceipt = (channel, messageId) => sendReadReceipt(this, channel, messageId);
sendDeliveredReceipt = (channel, messageId) => sendDeliveredReceipt(this, channel, messageId);
@ -103,7 +106,6 @@ class P2PNetwork {
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;
@ -152,7 +154,6 @@ class P2PNetwork {
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);
@ -172,7 +173,8 @@ class P2PNetwork {
removeWebRTCListener = (fn) => removeWebRTCListener(this, fn);
sendWebRTCSignal = (target, payload) => sendWebRTCSignal(this, target, payload);
updateProfile = (name, avatar, username) => Identity.updateProfile(this, name, avatar, username);
updateProfile = (name, avatar, username, bio, connections) => Identity.updateProfile(this, name, avatar, username, bio, connections);
_checkPendingRequests = (uname, pubKey, profile) => {
if (this.pendingFriendRequests.has(uname)) {
this.pendingFriendRequests.delete(uname);
@ -246,7 +248,6 @@ class P2PNetwork {
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',
@ -284,7 +285,6 @@ class P2PNetwork {
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));
@ -297,7 +297,6 @@ class P2PNetwork {
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));
@ -317,12 +316,13 @@ class P2PNetwork {
}
}
async initialize(seedHex, displayName, username, avatar = null) {
async initialize(seedHex, displayName, username, avatar = null, bio = '', connections = []) {
this.displayName = displayName;
this.username = (username || 'unknown').toLowerCase();
this.avatar = avatar;
this.bio = bio;
this.connections = connections;
// Run time sync in the background so it doesn't block UI boot
this._syncTimeWithServer().catch(() => {});
let instanceId = 'default';
@ -391,15 +391,14 @@ class P2PNetwork {
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 });
this.knownProfiles.set(this.myKey, { displayName: this.displayName, username: this.username, avatar: this.avatar, bio: this.bio, connections: this.connections });
// 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 });
this.peers.set(peerKey, { conn, displayName: 'Unknown', username: 'unknown', avatar: null, bio: '', connections: [], coreKey: null });
const identityMsg = JSON.stringify({ type: 'identity', displayName: this.displayName, username: this.username, avatar: this.avatar, bio: this.bio, connections: this.connections, 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));
@ -409,7 +408,6 @@ class P2PNetwork {
});
});
// 2. JOIN ALL KNOWN TOPICS (Batched and Flushed to prevent NAT exhaustion)
const paceJoin = () => new Promise(resolve => setTimeout(resolve, 100));
let joinCount = 0;
@ -433,12 +431,10 @@ class P2PNetwork {
}
}
// 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
await this._joinTopic(server.topicHex, true);
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 {
@ -446,29 +442,24 @@ class P2PNetwork {
}
}
// 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));
@ -476,7 +467,7 @@ class P2PNetwork {
getPeerList() {
return Array.from(this.peers.entries()).map(([key, info]) => ({
key, displayName: info.displayName, username: info.username, avatar: info.avatar
key, displayName: info.displayName, username: info.username, avatar: info.avatar, bio: info.bio, connections: info.connections
}));
}
@ -537,32 +528,57 @@ class P2PNetwork {
total: 0,
dms: {},
servers: {},
files:[]
files: []
};
const guessServerName = (topicHex) => {
const s = this.servers.find(s => s.topicHex === topicHex);
if (s) return s.name;
for (const msg of this.messages.values()) {
if (msg.payload?.serverTopicHex === topicHex && msg.payload?.name) return msg.payload.name;
if (msg.payload?.type === 'server_invite' && msg.payload?.serverTopicHex === topicHex) return msg.payload.serverName;
if (msg.payload?.type === 'group_chat_add' && msg.payload?.topicHex === topicHex) return msg.payload.name;
}
return 'Unknown Hub';
};
for (const msg of this.messages.values()) {
if (msg.payload?.type === 'file' && msg.isMediaInDB) {
if (msg.payload?.type === 'file' && msg.localPath) {
const size = msg.payload.file.size || 0;
stats.total += size;
const target = msg.recipient ? (msg.sender === this.myKey ? msg.recipient : msg.sender) : null;
let serverName = null;
let isGroupChat = false;
if (!msg.recipient && msg.payload.channel) {
const topicHex = msg.payload.channel.substring(0, 64);
serverName = guessServerName(topicHex);
const s = this.servers.find(s => s.topicHex === topicHex);
if (s) isGroupChat = s.isGroupChat;
}
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
channel: msg.payload.channel,
recipient: msg.recipient,
sender: msg.sender,
target: target,
serverName: serverName,
isGroupChat: isGroupChat
};
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: {} };
} else if (msg.payload.channel) {
const topicHex = msg.payload.channel.substring(0, 64);
const channelName = msg.payload.channel.substring(65) || 'general';
if (!stats.servers[topicHex]) stats.servers[topicHex] = { total: 0, channels: {}, name: serverName, isGroupChat };
stats.servers[topicHex].total += size;
stats.servers[topicHex].channels[channelName] = (stats.servers[topicHex].channels[channelName] || 0) + size;
}

View File

@ -8,7 +8,6 @@ export async function searchUser(network, targetUsername) {
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 });
@ -27,7 +26,6 @@ export async function searchUser(network, targetUsername) {
finish(null);
}, 5000);
// Check periodically if they appeared in userDirectory after connecting
const interval = setInterval(() => {
if (network.userDirectory.has(normalized)) {
clearTimeout(timeout);
@ -36,7 +34,6 @@ export async function searchUser(network, targetUsername) {
}
}, 500);
// Also broadcast whois to existing peers just in case
const queryId = generateUUID();
network.pendingWhois.set(queryId, (result) => {
clearTimeout(timeout);
@ -69,20 +66,33 @@ export async function trackPeerCore(network, coreKeyHex) {
let processedSeq = -1;
// Process all existing messages
// Process already downloaded messages immediately to prevent hanging on boot
for (let i = 0; i < core.length; i++) {
const msg = await core.get(i);
network.processMessage(msg);
processedSeq = i;
if (core.has(i)) {
const msg = await core.get(i);
network.processMessage(msg);
processedSeq = Math.max(processedSeq, i);
}
}
// Listen for new messages and process sequentially to prevent skipping rapid appends
// Listen for newly downloaded blocks (historical sync)
core.on('download', async (index) => {
const msg = await core.get(index);
network.processMessage(msg);
});
// Listen for new messages appended live
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;
if (core.has(i)) {
const msg = await core.get(i);
network.processMessage(msg);
processedSeq = Math.max(processedSeq, i);
}
}
});
// Tell the core to download all blocks in the background
core.download({ start: 0, end: core.length });
}

View File

@ -41,9 +41,11 @@ export function decryptPayload(nonceHex, cipherHex, sharedSecret) {
}
}
export function updateProfile(network, displayName, avatar, username) {
export function updateProfile(network, displayName, avatar, username, bio = '', connections = []) {
network.displayName = displayName;
network.avatar = avatar;
network.bio = bio;
network.connections = connections;
if (username && username !== 'unknown' && network.username === 'unknown') {
network.username = username;
@ -52,13 +54,13 @@ export function updateProfile(network, displayName, avatar, 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.knownProfiles.set(network.myKey, { displayName, username: network.username, avatar, bio, connections });
if (network.profilesDb) network.profilesDb.put(network.myKey, { displayName, username: network.username, avatar, bio, connections });
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 identityMsg = JSON.stringify({ type: 'identity', displayName: network.displayName, username: network.username, avatar: network.avatar, bio: network.bio, connections: network.connections, coreKey: network.coreKey });
const payload = b4a.from(identityMsg);
for (const { conn } of network.peers.values()) conn.write(payload);
network._emitMessages();

View File

@ -9,7 +9,31 @@ export function getAllMessages(network) {
const ch = m.payload.channel;
if (ch && ch.length > 64 && ch[64] === '-') {
const topicHex = ch.substring(0, 64);
const chName = ch.substring(65);
if (!joinedTopics.has(topicHex)) return false;
const server = network.servers.find(s => s.topicHex === topicHex);
if (server && network.myKey !== server.owner && network.myKey !== ADMIN_PUBLIC_KEY) {
const userRoles = server.memberRoles?.[network.myKey] || [];
const isServerAdmin = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('admin');
});
if (!isServerAdmin) {
const channelPerms = server.channels?.permissions?.[chName];
if (channelPerms && channelPerms.length > 0) {
const hasChannelAccess = userRoles.some(rId => channelPerms.includes(rId));
if (!hasChannelAccess) return false;
}
const hasReadPerm = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('read_messages');
});
if (!hasReadPerm && server.roles && server.roles.length > 0) return false;
}
}
}
return true;
}).map(m => {
@ -29,10 +53,11 @@ export function getAllMessages(network) {
timestamp: m.payload.timestamp,
logicalTime: m.payload.logicalTime || 0,
edited: m.payload.edited || false,
replyTo: m.payload.replyTo || null,
reactions: m.reactions || {},
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
@ -84,6 +109,24 @@ export async function processMessage(network, msg) {
return;
}
if (decrypted.type === 'reaction') {
const targetMsg = network.messages.get(decrypted.targetId);
if (targetMsg) {
if (!targetMsg.reactions) targetMsg.reactions = {};
if (!targetMsg.reactions[decrypted.emoji]) targetMsg.reactions[decrypted.emoji] = [];
const idx = targetMsg.reactions[decrypted.emoji].indexOf(msg.sender);
if (idx > -1) {
targetMsg.reactions[decrypted.emoji].splice(idx, 1);
if (targetMsg.reactions[decrypted.emoji].length === 0) delete targetMsg.reactions[decrypted.emoji];
} else {
targetMsg.reactions[decrypted.emoji].push(msg.sender);
}
network._emitMessages();
}
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 };
@ -124,7 +167,7 @@ export async function processMessage(network, msg) {
}
} catch (err) { return; }
const { type, id, targetId, channel, text, serverTopicHex, allowAnyoneToInvite, name, icon, channels } = msg.payload;
const { type, id, targetId, channel, text, serverTopicHex, allowAnyoneToInvite, name, icon, channels, roles, memberRoles, emoji } = msg.payload;
if (type === 'server_delete') {
const server = network.servers.find(s => s.topicHex === serverTopicHex);
@ -135,10 +178,32 @@ export async function processMessage(network, msg) {
}
if (type === 'server_leave') {
const targetUser = msg.payload.targetUser || msg.sender;
if (targetUser !== msg.sender) {
const server = network.servers.find(s => s.topicHex === serverTopicHex);
if (server) {
let canKick = false;
if (msg.sender === server.owner || msg.sender === ADMIN_PUBLIC_KEY) canKick = true;
else {
const userRoles = server.memberRoles?.[msg.sender] || [];
canKick = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && (r.permissions.includes('admin') || r.permissions.includes('kick_members'));
});
}
if (!canKick) return;
}
}
if (network.serverMembers[serverTopicHex]) {
network.serverMembers[serverTopicHex].delete(msg.sender);
network.serverMembers[serverTopicHex].delete(targetUser);
network._emitServerMembers();
}
if (targetUser === network.myKey) {
network._wipeLocalServerData(serverTopicHex);
}
return;
}
@ -151,20 +216,124 @@ export async function processMessage(network, msg) {
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;
if (server) {
let canUpdateSettings = false;
let canManageChannels = false;
let canManageRoles = false;
if (msg.sender === server.owner || msg.sender === ADMIN_PUBLIC_KEY) {
canUpdateSettings = true;
canManageChannels = true;
canManageRoles = true;
} else {
const userRoles = server.memberRoles?.[msg.sender] || [];
const isServerAdmin = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('admin');
});
if (isServerAdmin) {
canUpdateSettings = true;
canManageChannels = true;
canManageRoles = true;
} else {
canManageChannels = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('manage_channels');
});
canManageRoles = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('manage_roles');
});
}
}
if (canUpdateSettings || canManageChannels || canManageRoles) {
if (canUpdateSettings && allowAnyoneToInvite !== undefined) server.allowAnyoneToInvite = allowAnyoneToInvite;
if (canUpdateSettings && name !== undefined) server.name = name;
if (canUpdateSettings && icon !== undefined) server.icon = icon;
if (canManageChannels && channels !== undefined) server.channels = channels;
if (canManageRoles && roles !== undefined) server.roles = roles;
if (canManageRoles && memberRoles !== undefined) server.memberRoles = memberRoles;
network.serverDb.put(serverTopicHex, server);
network._emitServers();
}
}
return;
}
if (type === 'reaction') {
let canReact = true;
const targetMsg = network.messages.get(targetId);
if (targetMsg && targetMsg.payload.channel && targetMsg.payload.channel.length > 64 && targetMsg.payload.channel[64] === '-') {
const topicHex = targetMsg.payload.channel.substring(0, 64);
const chName = targetMsg.payload.channel.substring(65);
const server = network.servers.find(s => s.topicHex === topicHex);
if (server && msg.sender !== server.owner && msg.sender !== ADMIN_PUBLIC_KEY) {
const userRoles = server.memberRoles?.[msg.sender] || [];
const isServerAdmin = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('admin');
});
if (!isServerAdmin) {
const channelPerms = server.channels?.permissions?.[chName];
if (channelPerms && channelPerms.length > 0) {
const hasChannelAccess = userRoles.some(rId => channelPerms.includes(rId));
if (!hasChannelAccess) canReact = false;
}
if (canReact) {
const hasReactPerm = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('add_reactions');
});
if (!hasReactPerm && server.roles && server.roles.length > 0) canReact = false;
}
}
}
}
if (canReact && targetMsg) {
if (!targetMsg.reactions) targetMsg.reactions = {};
if (!targetMsg.reactions[emoji]) targetMsg.reactions[emoji] = [];
network.serverDb.put(serverTopicHex, server);
network._emitServers();
const idx = targetMsg.reactions[emoji].indexOf(msg.sender);
if (idx > -1) {
targetMsg.reactions[emoji].splice(idx, 1);
if (targetMsg.reactions[emoji].length === 0) delete targetMsg.reactions[emoji];
} else {
targetMsg.reactions[emoji].push(msg.sender);
}
network._emitMessages();
}
return;
}
if (type === 'delete') {
if (msg.sender === ADMIN_PUBLIC_KEY || msg.sender === network.messages.get(targetId)?.sender) {
const targetMsg = network.messages.get(targetId);
if (!targetMsg) return;
let canDelete = false;
if (msg.sender === ADMIN_PUBLIC_KEY || msg.sender === targetMsg.sender) {
canDelete = true;
} else if (targetMsg.payload.channel) {
const topicHex = targetMsg.payload.channel.substring(0, 64);
const server = network.servers.find(s => s.topicHex === topicHex);
if (server) {
if (server.owner === msg.sender) canDelete = true;
const userRoles = server.memberRoles?.[msg.sender] || [];
const hasAdmin = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('admin');
});
if (hasAdmin) canDelete = true;
}
}
if (canDelete) {
network.deletedMessages.add(targetId);
network.messages.delete(targetId);
network._emitMessages();
@ -183,7 +352,53 @@ export async function processMessage(network, msg) {
}
if (type === 'chat' || type === 'file') {
if (!network.deletedMessages.has(id) && !network.messages.has(id)) {
let canAccept = true;
if (channel && channel.length > 64 && channel[64] === '-') {
const topicHex = channel.substring(0, 64);
const chName = channel.substring(65);
const server = network.servers.find(s => s.topicHex === topicHex);
if (server && msg.sender !== server.owner && msg.sender !== ADMIN_PUBLIC_KEY) {
const userRoles = server.memberRoles?.[msg.sender] || [];
const isServerAdmin = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('admin');
});
if (!isServerAdmin) {
const channelPerms = server.channels?.permissions?.[chName];
if (channelPerms && channelPerms.length > 0) {
const hasChannelAccess = userRoles.some(rId => channelPerms.includes(rId));
if (!hasChannelAccess) canAccept = false;
}
if (canAccept) {
const channelSendPerms = server.channels?.send_permissions?.[chName];
if (channelSendPerms && channelSendPerms.length > 0) {
const hasChannelSendAccess = userRoles.some(rId => channelSendPerms.includes(rId));
if (!hasChannelSendAccess) canAccept = false;
}
}
if (canAccept) {
const hasSendPerm = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('send_messages');
});
if (!hasSendPerm && server.roles && server.roles.length > 0) canAccept = false;
if (type === 'file') {
const hasFilePerm = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('send_files');
});
if (!hasFilePerm && server.roles && server.roles.length > 0) canAccept = false;
}
}
}
}
}
if (canAccept && !network.deletedMessages.has(id) && !network.messages.has(id)) {
network.messages.set(id, msg);
network._emitMessages();
@ -243,7 +458,7 @@ 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 } });
await _appendEncryptedMessage(network, targetKey, { type: 'dm_request', profile: { displayName: network.displayName, username: network.username, avatar: network.avatar, bio: network.bio, connections: network.connections } });
}
export async function acceptDMRequest(network, targetKey) {
@ -255,10 +470,25 @@ export async function acceptDMRequest(network, targetKey) {
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 async function sendMessage(network, channel, text, replyTo = null) {
await _appendSignedMessage(network, { type: 'chat', id: generateUUID(), channel, text, replyTo });
}
export async function sendDM(network, targetKey, text, replyTo = null) {
await _appendEncryptedMessage(network, targetKey, { type: 'dm_chat', id: generateUUID(), text, replyTo });
}
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 async function sendReaction(network, targetId, emoji, isDM = false, targetKey = null) {
if (isDM && targetKey) {
await _appendEncryptedMessage(network, targetKey, { type: 'reaction', id: generateUUID(), targetId, emoji });
} else {
await _appendSignedMessage(network, { type: 'reaction', id: generateUUID(), targetId, emoji });
}
}
export function sendEphemeral(network, payload) {
if (!network.swarm) return;

View File

@ -6,8 +6,26 @@ export async function createServer(network, name, icon, allowAnyoneToInvite, isG
sodium.randombytes_buf(topic);
const topicHex = b4a.toString(topic, 'hex');
const channels = { text: ['general-chat'], voice: ['general-voice'] };
const serverInfo = { name, icon, owner: network.myKey, allowAnyoneToInvite, isGroupChat, channels };
const channels = { text: ['general-chat'], voice: ['general-voice'], permissions: {}, send_permissions: {} };
// Default roles and permissions setup
const roles = [
{
id: 'admin',
name: 'Admin',
color: '#ff4444',
permissions: ['admin', 'send_messages', 'read_messages', 'manage_channels', 'manage_roles', 'kick_members', 'send_files', 'add_reactions', 'mention_everyone']
},
{
id: 'member',
name: 'Member',
color: '#9ca3af',
permissions: ['send_messages', 'read_messages', 'send_files', 'add_reactions']
}
];
const memberRoles = { [network.myKey]: ['admin'] };
const serverInfo = { name, icon, owner: network.myKey, allowAnyoneToInvite, isGroupChat, channels, roles, memberRoles };
network.servers.push({ topicHex, ...serverInfo });
network._emitServers();
@ -20,10 +38,19 @@ export async function createServer(network, name, icon, allowAnyoneToInvite, isG
return { topicHex, ...serverInfo };
}
export async function joinServer(network, topicHex, name, icon, owner, allowAnyoneToInvite, isGroupChat = false, channels = null) {
export async function joinServer(network, topicHex, name, icon, owner, allowAnyoneToInvite, isGroupChat = false, channels = null, roles = null, memberRoles = 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'] } };
const serverInfo = {
name,
icon,
owner,
allowAnyoneToInvite,
isGroupChat,
channels: channels || { text: ['general-chat'], voice: ['general-voice'], permissions: {}, send_permissions: {} },
roles: roles || [],
memberRoles: memberRoles || {}
};
network.servers.push({ topicHex, ...serverInfo });
network._emitServers();
@ -61,7 +88,9 @@ export async function sendServerInvite(network, targetKey, serverTopicHex) {
serverOwner: server.owner,
allowAnyoneToInvite: server.allowAnyoneToInvite,
isGroupChat: server.isGroupChat,
channels: server.channels
channels: server.channels,
roles: server.roles,
memberRoles: server.memberRoles
});
}
@ -77,11 +106,13 @@ export async function sendGroupChatAdd(network, targetKey, serverTopicHex) {
name: server.name,
icon: server.icon,
owner: server.owner,
channels: server.channels
channels: server.channels,
roles: server.roles,
memberRoles: server.memberRoles
});
}
export async function updateServerSettings(network, serverTopicHex, name, icon, allowAnyoneToInvite, channels) {
export async function updateServerSettings(network, serverTopicHex, name, icon, allowAnyoneToInvite, channels, roles, memberRoles) {
await network._appendSignedMessage({
type: 'server_settings_update',
serverTopicHex,
@ -89,6 +120,8 @@ export async function updateServerSettings(network, serverTopicHex, name, icon,
icon,
allowAnyoneToInvite,
channels,
roles,
memberRoles,
timestamp: Date.now()
});
@ -98,6 +131,8 @@ export async function updateServerSettings(network, serverTopicHex, name, icon,
if (icon !== undefined) server.icon = icon;
if (allowAnyoneToInvite !== undefined) server.allowAnyoneToInvite = allowAnyoneToInvite;
if (channels !== undefined) server.channels = channels;
if (roles !== undefined) server.roles = roles;
if (memberRoles !== undefined) server.memberRoles = memberRoles;
await network.serverDb.put(serverTopicHex, server);
network._emitServers();
}