v1.0.1 Update
This commit is contained in:
parent
b848c6612c
commit
dd2cdd23d7
|
|
@ -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 path from 'path';
|
||||||
import { fileURLToPath, pathToFileURL } from 'url';
|
import { fileURLToPath, pathToFileURL } from 'url';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
@ -11,14 +11,18 @@ const pkgPath = path.join(__dirname, 'package.json');
|
||||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
||||||
|
|
||||||
// Register custom protocol BEFORE app is ready to ensure localStorage persistence
|
// 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([
|
protocol.registerSchemesAsPrivileged([
|
||||||
{ scheme: 'peercord', privileges: { standard: true, secure: true, supportFetchAPI: true, bypassCSP: true, corsEnabled: true, stream: true } }
|
{ scheme: 'peercord', privileges: { standard: true, secure: true, supportFetchAPI: true, bypassCSP: true, corsEnabled: true, stream: true } }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Force app name and userData path BEFORE app.whenReady()
|
// 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';
|
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');
|
const appDataPath = path.join(app.getPath('appData'), 'Peercord');
|
||||||
app.setPath('userData', appDataPath);
|
app.setPath('userData', appDataPath);
|
||||||
|
|
||||||
|
|
@ -40,8 +44,6 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAppDir() {
|
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 execName = path.basename(process.execPath).toLowerCase();
|
||||||
const isDev = execName === 'electron.exe' || execName === 'electron';
|
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 === 'linux' && process.env.APPIMAGE) return process.env.APPIMAGE;
|
||||||
if (process.platform === 'darwin') return path.join(process.resourcesPath, '..', '..');
|
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);
|
return path.dirname(process.execPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
let globalWin = null;
|
let globalWin = null;
|
||||||
|
let tray = null;
|
||||||
|
let isQuitting = false;
|
||||||
let isWindowReady = false;
|
let isWindowReady = false;
|
||||||
let logQueue = [];
|
let logQueue = [];
|
||||||
|
let closeToTray = true;
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
isQuitting = true;
|
||||||
|
});
|
||||||
|
|
||||||
// Focus existing window if a second instance is launched
|
// Focus existing window if a second instance is launched
|
||||||
app.on('second-instance', (event, commandLine, workingDirectory) => {
|
app.on('second-instance', (event, commandLine, workingDirectory) => {
|
||||||
if (globalWin) {
|
if (globalWin) {
|
||||||
if (globalWin.isMinimized()) globalWin.restore();
|
if (globalWin.isMinimized()) globalWin.restore();
|
||||||
|
globalWin.show();
|
||||||
globalWin.focus();
|
globalWin.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -79,7 +87,6 @@ function logToRenderer(level, ...args) {
|
||||||
|
|
||||||
console[level](...formattedArgs);
|
console[level](...formattedArgs);
|
||||||
|
|
||||||
// Queue logs if the React window hasn't finished loading yet!
|
|
||||||
if (globalWin && isWindowReady && !globalWin.isDestroyed()) {
|
if (globalWin && isWindowReady && !globalWin.isDestroyed()) {
|
||||||
globalWin.webContents.send('main-log', { level, args: formattedArgs });
|
globalWin.webContents.send('main-log', { level, args: formattedArgs });
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -87,7 +94,6 @@ function logToRenderer(level, ...args) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush the log queue the millisecond React says it's ready
|
|
||||||
ipcMain.on('renderer-ready', () => {
|
ipcMain.on('renderer-ready', () => {
|
||||||
isWindowReady = true;
|
isWindowReady = true;
|
||||||
logQueue.forEach(log => {
|
logQueue.forEach(log => {
|
||||||
|
|
@ -98,8 +104,11 @@ ipcMain.on('renderer-ready', () => {
|
||||||
logQueue = [];
|
logQueue = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.on('set-tray-setting', (e, val) => {
|
||||||
|
closeToTray = val;
|
||||||
|
});
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
// Handle Squirrel.Windows startup events to prevent multiple background launches
|
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
const cmd = process.argv[1];
|
const cmd = process.argv[1];
|
||||||
if (cmd === '--squirrel-install' || cmd === '--squirrel-updated' || cmd === '--squirrel-uninstall' || cmd === '--squirrel-obsolete') {
|
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();
|
app.disableHardwareAcceleration();
|
||||||
|
|
||||||
await app.whenReady();
|
await app.whenReady();
|
||||||
|
|
||||||
const appPath = app.getAppPath();
|
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) => {
|
protocol.handle('peercord', (request) => {
|
||||||
// Safely serve local files (images/videos) bypassing Electron's file:// restrictions
|
|
||||||
if (request.url.startsWith('peercord://local/')) {
|
if (request.url.startsWith('peercord://local/')) {
|
||||||
let filePath = decodeURIComponent(request.url.replace('peercord://local/', ''));
|
let filePath = decodeURIComponent(request.url.replace('peercord://local/', ''));
|
||||||
if (process.platform === 'win32' && filePath.startsWith('/')) {
|
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);
|
return net.fetch(pathToFileURL(filePath).href);
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +140,6 @@ async function boot() {
|
||||||
|
|
||||||
let filePath = path.join(appPath, url);
|
let filePath = path.join(appPath, url);
|
||||||
|
|
||||||
// Fallback to dist/ if not found (helps with absolute paths in index.html)
|
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
const distPath = path.join(appPath, 'dist', url);
|
const distPath = path.join(appPath, 'dist', url);
|
||||||
if (fs.existsSync(distPath)) filePath = distPath;
|
if (fs.existsSync(distPath)) filePath = distPath;
|
||||||
|
|
@ -144,14 +148,14 @@ async function boot() {
|
||||||
return net.fetch(pathToFileURL(filePath).href);
|
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 iconFile = process.platform === 'win32' ? 'icon.ico' : 'icon.png';
|
||||||
|
const iconPath = path.join(appPath, 'assets', iconFile);
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 1100,
|
width: 1100,
|
||||||
height: 750,
|
height: 750,
|
||||||
title: "Peercord",
|
title: "Peercord",
|
||||||
icon: path.join(appPath, 'assets', iconFile),
|
icon: iconPath,
|
||||||
frame: false,
|
frame: false,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
maximizable: true,
|
maximizable: true,
|
||||||
|
|
@ -165,10 +169,47 @@ async function boot() {
|
||||||
|
|
||||||
globalWin = win;
|
globalWin = win;
|
||||||
|
|
||||||
// Load via custom protocol instead of file:// to prevent localStorage wipes on Linux
|
|
||||||
win.loadURL('peercord://app/dist/index.html');
|
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) => {
|
win.webContents.on('before-input-event', (event, input) => {
|
||||||
if (input.key === 'F12') {
|
if (input.key === 'F12') {
|
||||||
win.webContents.toggleDevTools();
|
win.webContents.toggleDevTools();
|
||||||
|
|
@ -176,18 +217,15 @@ async function boot() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bridge for TitleBar.jsx custom controls
|
|
||||||
ipcMain.on('window-action', (event, action) => {
|
ipcMain.on('window-action', (event, action) => {
|
||||||
if (action === 'minimize') win.minimize();
|
if (action === 'minimize') win.minimize();
|
||||||
if (action === 'maximize') win.isMaximized() ? win.restore() : win.maximize();
|
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('maximize', () => win.webContents.send('window-state-changed', true));
|
||||||
win.on('unmaximize', () => win.webContents.send('window-state-changed', false));
|
win.on('unmaximize', () => win.webContents.send('window-state-changed', false));
|
||||||
|
|
||||||
// Bridge for ScreenShareModal.jsx desktop sources
|
|
||||||
ipcMain.handle('get-desktop-sources', async () => {
|
ipcMain.handle('get-desktop-sources', async () => {
|
||||||
const sources = await desktopCapturer.getSources({
|
const sources = await desktopCapturer.getSources({
|
||||||
types:['window', 'screen'],
|
types:['window', 'screen'],
|
||||||
|
|
@ -201,11 +239,7 @@ async function boot() {
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
// PEAR OTA UPDATER LOGIC
|
// PEAR OTA UPDATER LOGIC
|
||||||
// ---------------------------------------------------------
|
|
||||||
|
|
||||||
// Initialize Embedded Pear Runtime AFTER window creation so logs are queued properly
|
|
||||||
let pear = null;
|
let pear = null;
|
||||||
try {
|
try {
|
||||||
const { default: PearRuntime } = await import('pear-runtime');
|
const { default: PearRuntime } = await import('pear-runtime');
|
||||||
|
|
@ -214,14 +248,14 @@ async function boot() {
|
||||||
logToRenderer('info', '[Pear] Resolved App Directory for Updater:', resolvedAppDir);
|
logToRenderer('info', '[Pear] Resolved App Directory for Updater:', resolvedAppDir);
|
||||||
|
|
||||||
pear = new PearRuntime({
|
pear = new PearRuntime({
|
||||||
...pkg, // Spread pkg to ensure updates: true is passed
|
...pkg,
|
||||||
dir: path.join(app.getPath('userData'), 'pear-data'),
|
dir: path.join(app.getPath('userData'), 'pear-data'),
|
||||||
app: resolvedAppDir
|
app: resolvedAppDir
|
||||||
});
|
});
|
||||||
|
|
||||||
pear.on('error', (err) => logToRenderer('error', '[Pear Error]', err));
|
pear.on('error', (err) => logToRenderer('error', '[Pear Error]', err));
|
||||||
} catch (e) {
|
} 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) {
|
if (pear && pear.updater) {
|
||||||
|
|
@ -247,8 +281,8 @@ async function boot() {
|
||||||
logToRenderer('warn', '[Pear] Updater not available on pear object');
|
logToRenderer('warn', '[Pear] Updater not available on pear object');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safe restart for Gossip protocol (reboots to find new seeder)
|
|
||||||
ipcMain.on('normal-restart', () => {
|
ipcMain.on('normal-restart', () => {
|
||||||
|
isQuitting = true;
|
||||||
app.relaunch();
|
app.relaunch();
|
||||||
app.quit();
|
app.quit();
|
||||||
});
|
});
|
||||||
|
|
@ -312,6 +346,7 @@ WshShell.Run "cmd.exe /c """"" & WScript.Arguments(0) & """ """ & WScript.Argume
|
||||||
child.unref();
|
child.unref();
|
||||||
|
|
||||||
logToRenderer('info', '[Pear] Detached script spawned. Quitting app to allow swap...');
|
logToRenderer('info', '[Pear] Detached script spawned. Quitting app to allow swap...');
|
||||||
|
isQuitting = true;
|
||||||
app.quit();
|
app.quit();
|
||||||
} else {
|
} else {
|
||||||
logToRenderer('info', '[Pear] macOS/Linux detected. Using detached bash script for reliable directory swap...');
|
logToRenderer('info', '[Pear] macOS/Linux detected. Using detached bash script for reliable directory swap...');
|
||||||
|
|
@ -346,6 +381,7 @@ rm "$0"
|
||||||
child.unref();
|
child.unref();
|
||||||
|
|
||||||
logToRenderer('info', '[Pear] Detached script spawned. Quitting app to allow swap...');
|
logToRenderer('info', '[Pear] Detached script spawned. Quitting app to allow swap...');
|
||||||
|
isQuitting = true;
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "peercord",
|
"name": "peercord",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"description": "Peercord, A P2P Discord clone powered by Pear Runtime",
|
"description": "Peercord, A P2P Discord clone powered by Pear Runtime",
|
||||||
"author": "Mastercodeon",
|
"author": "Mastercodeon",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"upgrade": "[PEAR_LINK]",
|
"upgrade": "pear://wmir47w7mai3b1skj66mx7fzso6k6o91kipaney7gtt69npimouy",
|
||||||
"updates": true,
|
"updates": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"bump": "node scripts/version.js",
|
"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: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: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: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:stage": "pear stage pear://wmir47w7mai3b1skj66mx7fzso6k6o91kipaney7gtt69npimouy out/build",
|
||||||
"pear:seed": "pear seed [PEAR_LINK]",
|
"pear:seed": "pear seed pear://wmir47w7mai3b1skj66mx7fzso6k6o91kipaney7gtt69npimouy",
|
||||||
"broadcast": "node scripts/broadcast-update.js",
|
"broadcast": "node scripts/broadcast-update.js",
|
||||||
"genkeys": "node scripts/genkeys.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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export default function CallView({ targetKey, targetProfile, myProfile, isCaller
|
||||||
|
|
||||||
const pcRef = useRef(null);
|
const pcRef = useRef(null);
|
||||||
const localStreamRef = useRef(null);
|
const localStreamRef = useRef(null);
|
||||||
|
const localClonedAudioStreamRef = useRef(null);
|
||||||
const localScreenStreamRef = useRef(null);
|
const localScreenStreamRef = useRef(null);
|
||||||
const localCameraStreamRef = useRef(null);
|
const localCameraStreamRef = useRef(null);
|
||||||
|
|
||||||
|
|
@ -125,9 +126,26 @@ export default function CallView({ targetKey, targetProfile, myProfile, isCaller
|
||||||
const setupMedia = async () => {
|
const setupMedia = async () => {
|
||||||
try {
|
try {
|
||||||
const audioInputId = localStorage.getItem('pear_audio_input');
|
const audioInputId = localStorage.getItem('pear_audio_input');
|
||||||
const aStream = await navigator.mediaDevices.getUserMedia({
|
const noiseSuppression = localStorage.getItem('pear_noise_suppression') !== 'false';
|
||||||
audio: audioInputId && audioInputId !== 'default' ? { deviceId: { exact: audioInputId } } : true
|
|
||||||
});
|
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;
|
localStreamRef.current = aStream;
|
||||||
|
|
||||||
if (initialVideoOn) {
|
if (initialVideoOn) {
|
||||||
|
|
@ -147,10 +165,27 @@ export default function CallView({ targetKey, targetProfile, myProfile, isCaller
|
||||||
|
|
||||||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
audioCtxRef.current = audioCtx;
|
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 analyser = audioCtx.createAnalyser();
|
||||||
const source = audioCtx.createMediaStreamSource(aStream);
|
|
||||||
source.connect(analyser);
|
|
||||||
analyser.fftSize = 256;
|
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 bufferLength = analyser.frequencyBinCount;
|
||||||
const dataArray = new Uint8Array(bufferLength);
|
const dataArray = new Uint8Array(bufferLength);
|
||||||
|
|
||||||
|
|
@ -188,6 +223,7 @@ export default function CallView({ targetKey, targetProfile, myProfile, isCaller
|
||||||
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
||||||
if (audioCtxRef.current) audioCtxRef.current.close().catch(() => {});
|
if (audioCtxRef.current) audioCtxRef.current.close().catch(() => {});
|
||||||
if (localStreamRef.current) localStreamRef.current.getTracks().forEach(t => t.stop());
|
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 (localScreenStreamRef.current) localScreenStreamRef.current.getTracks().forEach(t => t.stop());
|
||||||
if (localCameraStreamRef.current) localCameraStreamRef.current.getTracks().forEach(t => t.stop());
|
if (localCameraStreamRef.current) localCameraStreamRef.current.getTracks().forEach(t => t.stop());
|
||||||
if (pcRef.current) pcRef.current.close();
|
if (pcRef.current) pcRef.current.close();
|
||||||
|
|
|
||||||
|
|
@ -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 { network, ADMIN_PUBLIC_KEY } from '../p2p/index.js';
|
||||||
import CreateChannelModal from './CreateChannelModal.jsx';
|
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 activeServerObj = servers.find(s => s.topicHex === activeView);
|
||||||
const serverName = activeServerObj ? activeServerObj.name : 'Unknown Hub';
|
const serverName = activeServerObj ? activeServerObj.name : 'Unknown Hub';
|
||||||
|
|
||||||
const isAdmin = activeServerObj?.owner === myKey;
|
|
||||||
const canInvite = isAdmin || activeServerObj?.allowAnyoneToInvite;
|
|
||||||
|
|
||||||
const currentMembers = new Set(serverMembers[activeView] ||[]);
|
const currentMembers = new Set(serverMembers[activeView] ||[]);
|
||||||
if (activeServerObj) currentMembers.add(activeServerObj.owner);
|
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 textChannels = activeServerObj?.channels?.text || ['general-chat'];
|
||||||
const voiceChannels = activeServerObj?.channels?.voice || ['general-voice'];
|
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 handleCreateChannel = (name, type) => {
|
||||||
const newChannels = {
|
const newChannels = {
|
||||||
text: [...textChannels],
|
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);
|
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>
|
<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>
|
</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">
|
<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
|
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>
|
<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">
|
<div className="px-2 py-1 text-xs font-bold text-muted uppercase mt-2 flex justify-between items-center">
|
||||||
<span>Text Rooms</span>
|
<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>
|
</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">
|
<div className="px-2 py-1 mt-4 text-xs font-bold text-muted uppercase flex justify-between items-center">
|
||||||
<span>Voice Rooms</span>
|
<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>
|
</div>
|
||||||
{voiceChannels.map(ch => renderVoiceChannel(ch, ch))}
|
{visibleVoiceChannels.map(ch => renderVoiceChannel(ch, ch))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -37,6 +37,7 @@ export default function GroupCallView({ channel, serverTopicHex, vcChannelId, my
|
||||||
const ignoreOffer = useRef({});
|
const ignoreOffer = useRef({});
|
||||||
|
|
||||||
const localStreamRef = useRef(null);
|
const localStreamRef = useRef(null);
|
||||||
|
const localClonedAudioStreamRef = useRef(null);
|
||||||
const localScreenStreamRef = useRef(null);
|
const localScreenStreamRef = useRef(null);
|
||||||
const localCameraStreamRef = useRef(null);
|
const localCameraStreamRef = useRef(null);
|
||||||
const audioCtxRef = useRef(null);
|
const audioCtxRef = useRef(null);
|
||||||
|
|
@ -128,9 +129,26 @@ export default function GroupCallView({ channel, serverTopicHex, vcChannelId, my
|
||||||
const setupMedia = async () => {
|
const setupMedia = async () => {
|
||||||
try {
|
try {
|
||||||
const audioInputId = localStorage.getItem('pear_audio_input');
|
const audioInputId = localStorage.getItem('pear_audio_input');
|
||||||
const aStream = await navigator.mediaDevices.getUserMedia({
|
const noiseSuppression = localStorage.getItem('pear_noise_suppression') !== 'false';
|
||||||
audio: audioInputId && audioInputId !== 'default' ? { deviceId: { exact: audioInputId } } : true
|
|
||||||
});
|
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;
|
localStreamRef.current = aStream;
|
||||||
|
|
||||||
if (initialVideoOn) {
|
if (initialVideoOn) {
|
||||||
|
|
@ -148,10 +166,27 @@ export default function GroupCallView({ channel, serverTopicHex, vcChannelId, my
|
||||||
|
|
||||||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
audioCtxRef.current = audioCtx;
|
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 analyser = audioCtx.createAnalyser();
|
||||||
const source = audioCtx.createMediaStreamSource(aStream);
|
|
||||||
source.connect(analyser);
|
|
||||||
analyser.fftSize = 256;
|
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 bufferLength = analyser.frequencyBinCount;
|
||||||
const dataArray = new Uint8Array(bufferLength);
|
const dataArray = new Uint8Array(bufferLength);
|
||||||
|
|
||||||
|
|
@ -203,6 +238,7 @@ export default function GroupCallView({ channel, serverTopicHex, vcChannelId, my
|
||||||
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
||||||
if (audioCtxRef.current) audioCtxRef.current.close().catch(() => {});
|
if (audioCtxRef.current) audioCtxRef.current.close().catch(() => {});
|
||||||
if (localStreamRef.current) localStreamRef.current.getTracks().forEach(t => t.stop());
|
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 (localScreenStreamRef.current) localScreenStreamRef.current.getTracks().forEach(t => t.stop());
|
||||||
if (localCameraStreamRef.current) localCameraStreamRef.current.getTracks().forEach(t => t.stop());
|
if (localCameraStreamRef.current) localCameraStreamRef.current.getTracks().forEach(t => t.stop());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,63 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
|
||||||
|
|
||||||
const[vcStates, setVcStates] = useState({});
|
const[vcStates, setVcStates] = useState({});
|
||||||
const[showMembersDrawer, setShowMembersDrawer] = useState(false);
|
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 [isNetworkOnline, setIsNetworkOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true);
|
||||||
|
|
||||||
const initialized = useRef(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
|
|
@ -100,7 +153,101 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
network.onKnownProfilesUpdate = (users) => setKnownUsers(users);
|
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.onDMsUpdate = (updatedDms) => setDms(updatedDms);
|
||||||
network.onTransfersUpdate = (t) => setTransfers({...t});
|
network.onTransfersUpdate = (t) => setTransfers({...t});
|
||||||
network.onServersUpdate = (srvs) => {
|
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 => {
|
.catch(err => {
|
||||||
alert("P2P Initialization Error:\n" + err.message + "\n\nPress F12 to open DevTools for more info.");
|
alert("P2P Initialization Error:\n" + err.message + "\n\nPress F12 to open DevTools for more info.");
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
@ -241,7 +388,13 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleWebRTC = (peerKey, payload) => {
|
const handleWebRTC = (peerKey, payload) => {
|
||||||
|
const notifyCalls = localStorage.getItem('pear_notify_calls') !== 'false';
|
||||||
|
|
||||||
if (payload.type === 'webrtc-init') {
|
if (payload.type === 'webrtc-init') {
|
||||||
|
if (!notifyCalls) {
|
||||||
|
network.sendWebRTCSignal(peerKey, { type: 'webrtc-busy' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!activeCall && !activeGroupCall && !activeVc) {
|
if (!activeCall && !activeGroupCall && !activeVc) {
|
||||||
const callerProfile = knownUsers.find(u => u.key === peerKey) || dms[peerKey]?.profile || { displayName: 'Unknown' };
|
const callerProfile = knownUsers.find(u => u.key === peerKey) || dms[peerKey]?.profile || { displayName: 'Unknown' };
|
||||||
setIncomingCall({ isGroup: false, targetKey: peerKey, profile: callerProfile, callType: payload.callType || 'voice' });
|
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);
|
setActiveCall(current => current?.targetKey === peerKey ? null : current);
|
||||||
}
|
}
|
||||||
else if (payload.type === 'webrtc-group-ring') {
|
else if (payload.type === 'webrtc-group-ring') {
|
||||||
|
if (!notifyCalls) return;
|
||||||
const gc = servers.find(s => s.topicHex === payload.channel && s.isGroupChat);
|
const gc = servers.find(s => s.topicHex === payload.channel && s.isGroupChat);
|
||||||
if (gc && activeGroupCall?.channel !== payload.channel && !activeVc) {
|
if (gc && activeGroupCall?.channel !== payload.channel && !activeVc) {
|
||||||
setIncomingCall({ isGroup: true, channel: payload.channel, callerName: payload.callerName, gcName: gc.name, callType: payload.callType || 'voice' });
|
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);
|
return () => network.removeWebRTCListener(handleWebRTC);
|
||||||
},[activeCall, activeGroupCall, activeVc, knownUsers, dms, servers]);
|
},[activeCall, activeGroupCall, activeVc, knownUsers, dms, servers]);
|
||||||
|
|
||||||
const handleSaveProfile = (newName, newAvatar, newUsername) => {
|
const handleSaveProfile = (newName, newAvatar, newUsername, newBio, newConnections) => {
|
||||||
const updatedProfile = { ...profile, displayName: newName, avatar: newAvatar, username: newUsername || profile.username };
|
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 accounts = JSON.parse(localStorage.getItem('pear_saved_accounts') || '[]');
|
||||||
const existingIndex = accounts.findIndex(a => a.seedHex === profile.seedHex);
|
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));
|
localStorage.setItem('pear_discord_identity', JSON.stringify(updatedProfile));
|
||||||
setProfile(updatedProfile);
|
setProfile(updatedProfile);
|
||||||
network.updateProfile(newName, newAvatar, newUsername);
|
network.updateProfile(newName, newAvatar, newUsername, newBio, newConnections);
|
||||||
setIsSettingsOpen(false);
|
setIsSettingsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -442,6 +603,11 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
|
||||||
setShowChatInCall(false);
|
setShowChatInCall(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNavigateToDM = (pubKey) => {
|
||||||
|
setActiveView('dms');
|
||||||
|
setActiveDm(pubKey);
|
||||||
|
};
|
||||||
|
|
||||||
const unreadCounts = {};
|
const unreadCounts = {};
|
||||||
messages.forEach(m => {
|
messages.forEach(m => {
|
||||||
const channelId = m.recipient ? (m.sender === myKey ? m.recipient : m.sender) : m.channel;
|
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 isGroupChat = activeView === 'dms' && servers.some(s => s.topicHex === activeDm && s.isGroupChat);
|
||||||
const inviteServerObj = servers.find(s => s.topicHex === inviteModalServer);
|
const inviteServerObj = servers.find(s => s.topicHex === inviteModalServer);
|
||||||
|
|
||||||
|
const showMembersPanel = activeView !== 'dms' || isGroupChat;
|
||||||
|
const isPinned = pinMembers && showMembersPanel;
|
||||||
|
const isDrawerOpen = showMembersDrawer && showMembersPanel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full bg-base font-sans overflow-hidden relative">
|
<div className="flex h-full w-full bg-base font-sans overflow-hidden relative">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
|
|
@ -465,6 +635,7 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
|
||||||
setActiveView={setActiveView}
|
setActiveView={setActiveView}
|
||||||
servers={servers}
|
servers={servers}
|
||||||
myKey={myKey}
|
myKey={myKey}
|
||||||
|
unreadCounts={unreadCounts}
|
||||||
onOpenCreateServer={() => setIsCreateServerOpen(true)}
|
onOpenCreateServer={() => setIsCreateServerOpen(true)}
|
||||||
onLeaveServer={(topicHex) => {
|
onLeaveServer={(topicHex) => {
|
||||||
network.leaveServer(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">
|
<div className="flex-1 relative overflow-hidden flex">
|
||||||
|
|
||||||
{/* Chat Area (Hidden if CallView is active) */}
|
{/* 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' ? (
|
{activeView === 'dms' && activeDm === 'friends' ? (
|
||||||
<FriendsView dms={dms} />
|
<FriendsView dms={dms} />
|
||||||
) : (
|
) : (
|
||||||
<ChatArea
|
<ChatArea
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
activeChannel={activeView === 'dms' ? activeDm : activeChannel}
|
activeChannel={activeView === 'dms' ? activeDm : activeChannel}
|
||||||
|
setActiveChannel={activeView === 'dms' ? setActiveDm : setActiveChannel}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
myKey={myKey}
|
myKey={myKey}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
|
|
@ -560,6 +732,8 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
|
||||||
transfers={transfers}
|
transfers={transfers}
|
||||||
onOpenInvite={(topicHex) => setInviteModalServer(topicHex)}
|
onOpenInvite={(topicHex) => setInviteModalServer(topicHex)}
|
||||||
onToggleMembers={() => setShowMembersDrawer(!showMembersDrawer)}
|
onToggleMembers={() => setShowMembersDrawer(!showMembersDrawer)}
|
||||||
|
pinMembers={pinMembers}
|
||||||
|
onNavigateToDM={handleNavigateToDM}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -567,7 +741,7 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
|
||||||
{/* 1-on-1 Call View */}
|
{/* 1-on-1 Call View */}
|
||||||
{activeCall && (
|
{activeCall && (
|
||||||
<CallView
|
<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}
|
targetKey={activeCall.targetKey}
|
||||||
targetProfile={activeCall.profile}
|
targetProfile={activeCall.profile}
|
||||||
myProfile={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) */}
|
{/* Group Call View (Used for both DMs and Server VCs) */}
|
||||||
{(activeGroupCall || activeVc) && (
|
{(activeGroupCall || activeVc) && (
|
||||||
<GroupCallView
|
<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}`}
|
channel={activeGroupCall?.channel || `${activeVc.serverId}-${activeVc.channelId}`}
|
||||||
serverTopicHex={activeVc?.serverId}
|
serverTopicHex={activeVc?.serverId}
|
||||||
vcChannelId={activeVc?.channelId}
|
vcChannelId={activeVc?.channelId}
|
||||||
|
|
@ -629,19 +803,23 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Members Drawer */}
|
{/* 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'}`}>
|
{showMembersPanel && (
|
||||||
<OnlineUsers
|
<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`}>
|
||||||
onlinePeers={onlinePeers}
|
<OnlineUsers
|
||||||
knownUsers={knownUsers}
|
onlinePeers={onlinePeers}
|
||||||
dms={dms}
|
knownUsers={knownUsers}
|
||||||
myKey={myKey}
|
dms={dms}
|
||||||
profile={profile}
|
myKey={myKey}
|
||||||
activeView={activeView === 'dms' ? activeDm : activeView}
|
profile={profile}
|
||||||
servers={servers}
|
activeView={activeView === 'dms' ? activeDm : activeView}
|
||||||
serverMembers={serverMembers}
|
servers={servers}
|
||||||
onClose={() => setShowMembersDrawer(false)}
|
serverMembers={serverMembers}
|
||||||
/>
|
onClose={() => setShowMembersDrawer(false)}
|
||||||
</div>
|
pinMembers={pinMembers}
|
||||||
|
onNavigateToDM={handleNavigateToDM}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -687,6 +865,7 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
|
||||||
<ServerSettingsModal
|
<ServerSettingsModal
|
||||||
onClose={() => setSettingsModalServer(null)}
|
onClose={() => setSettingsModalServer(null)}
|
||||||
activeServerObj={servers.find(s => s.topicHex === settingsModalServer)}
|
activeServerObj={servers.find(s => s.topicHex === settingsModalServer)}
|
||||||
|
myKey={myKey}
|
||||||
onDeleteServer={() => {
|
onDeleteServer={() => {
|
||||||
network.deleteServer(settingsModalServer);
|
network.deleteServer(settingsModalServer);
|
||||||
setSettingsModalServer(null);
|
setSettingsModalServer(null);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { network, ADMIN_PUBLIC_KEY } from '../p2p/index.js';
|
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 }) {
|
export default function OnlineUsers({ onlinePeers, knownUsers, dms, myKey, profile, activeView, servers, serverMembers, onClose, pinMembers, onNavigateToDM }) {
|
||||||
|
const [selectedUser, setSelectedUser] = useState(null);
|
||||||
const handleSendRequest = (e, peer) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
network.sendDMRequest(peer.key, { displayName: peer.displayName, username: peer.username, avatar: peer.avatar });
|
|
||||||
};
|
|
||||||
|
|
||||||
const isCustomServer = activeView !== 'dms';
|
const isCustomServer = activeView !== 'dms';
|
||||||
const serverObj = isCustomServer ? servers.find(s => s.topicHex === activeView) : null;
|
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);
|
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 allOnlinePeers = [me, ...onlinePeers];
|
||||||
|
|
||||||
const filteredOnlinePeers = isCustomServer ? allOnlinePeers.filter(p => currentMembers.has(p.key)) : allOnlinePeers;
|
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) {
|
if (!onlineKeys.has(key) && key !== myKey) {
|
||||||
const known = knownUsers.find(u => u.key === key);
|
const known = knownUsers.find(u => u.key === key);
|
||||||
if (known) offlineUsers.push(known);
|
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 {
|
} else {
|
||||||
|
|
@ -39,13 +36,16 @@ export default function OnlineUsers({ onlinePeers, knownUsers, dms, myKey, profi
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderUser = (peer, isOnline) => {
|
const renderUser = (peer, isOnline) => {
|
||||||
const dmState = dms[peer.key]?.status;
|
|
||||||
let isPlatformAdmin = peer.key === ADMIN_PUBLIC_KEY;
|
let isPlatformAdmin = peer.key === ADMIN_PUBLIC_KEY;
|
||||||
let isServerOwner = isCustomServer && !isGroupChat && serverObj?.owner === peer.key;
|
let isServerOwner = isCustomServer && !isGroupChat && serverObj?.owner === peer.key;
|
||||||
let isGroupCreator = isGroupChat && serverObj?.owner === peer.key;
|
let isGroupCreator = isGroupChat && serverObj?.owner === peer.key;
|
||||||
|
|
||||||
return (
|
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="flex items-center gap-3 overflow-hidden">
|
||||||
<div className="relative shrink-0 w-8 h-8">
|
<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'}`}>
|
<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>
|
<span className="text-muted text-[10px] truncate">@{peer.username}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="flex justify-between items-center mb-4">
|
||||||
<div className="text-xs font-bold text-muted uppercase">
|
<div className="text-xs font-bold text-muted uppercase">
|
||||||
{isGroupChat ? 'Members' : 'Online'} — {filteredOnlinePeers.length}
|
{isGroupChat ? 'Members' : 'Online'} — {filteredOnlinePeers.length}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="text-muted hover:text-text">
|
{!pinMembers && (
|
||||||
<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 onClick={onClose} className="text-muted hover:text-text">
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
<div className="space-y-1 mb-6">
|
<div className="space-y-1 mb-6">
|
||||||
|
|
@ -106,6 +98,19 @@ export default function OnlineUsers({ onlinePeers, knownUsers, dms, myKey, profi
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -66,12 +66,10 @@ function StorageSettings({ dms, servers, knownUsers }) {
|
||||||
<h4 className="text-muted uppercase text-xs font-bold mb-3">Hubs</h4>
|
<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">
|
<div className="space-y-2 max-h-48 overflow-y-auto custom-scrollbar pr-2">
|
||||||
{Object.entries(stats.servers).map(([topicHex, data]) => {
|
{Object.entries(stats.servers).map(([topicHex, data]) => {
|
||||||
const server = servers.find(s => s.topicHex === topicHex);
|
|
||||||
const name = server ? server.name : 'Unknown Hub';
|
|
||||||
return (
|
return (
|
||||||
<div key={topicHex} className="flex flex-col bg-panel p-2 rounded gap-1">
|
<div key={topicHex} className="flex flex-col bg-panel p-2 rounded gap-1">
|
||||||
<div className="flex justify-between items-center">
|
<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>
|
<span className="text-sm font-mono text-muted shrink-0">{formatBytes(data.total)}</span>
|
||||||
</div>
|
</div>
|
||||||
{Object.entries(data.channels).map(([ch, size]) => (
|
{Object.entries(data.channels).map(([ch, size]) => (
|
||||||
|
|
@ -92,23 +90,40 @@ function StorageSettings({ dms, servers, knownUsers }) {
|
||||||
<div className="bg-surface rounded-lg p-6">
|
<div className="bg-surface rounded-lg p-6">
|
||||||
<h3 className="text-muted uppercase text-xs font-bold mb-4">Large Files</h3>
|
<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">
|
<div className="space-y-2 max-h-64 overflow-y-auto custom-scrollbar pr-2">
|
||||||
{stats.files.slice(0, 50).map(file => (
|
{stats.files.slice(0, 50).map(file => {
|
||||||
<div key={file.id} className="flex justify-between items-center bg-panel p-3 rounded group">
|
let originText = 'Unknown Origin';
|
||||||
<div className="flex flex-col overflow-hidden pr-4">
|
if (file.target) {
|
||||||
<span className="text-sm text-text font-medium truncate">{file.name}</span>
|
const name = dms[file.target]?.profile?.displayName || knownUsers.find(u => u.key === file.target)?.displayName || 'Unknown User';
|
||||||
<span className="text-xs text-muted">{new Date(file.timestamp).toLocaleString()}</span>
|
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>
|
||||||
<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>}
|
{stats.files.length === 0 && <div className="text-sm text-muted text-center py-4">No large files found.</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -120,6 +135,7 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
|
||||||
const[activeTab, setActiveTab] = useState('account');
|
const[activeTab, setActiveTab] = useState('account');
|
||||||
const[tempName, setTempName] = useState(profile.displayName);
|
const[tempName, setTempName] = useState(profile.displayName);
|
||||||
const[tempAvatar, setTempAvatar] = useState(profile.avatar);
|
const[tempAvatar, setTempAvatar] = useState(profile.avatar);
|
||||||
|
const[tempBio, setTempBio] = useState(profile.bio || '');
|
||||||
const[showSeed, setShowSeed] = useState(false);
|
const[showSeed, setShowSeed] = useState(false);
|
||||||
const fileInputRef = useRef(null);
|
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 [autoRestart, setAutoRestart] = useState(localStorage.getItem('pear_auto_restart') !== 'false');
|
||||||
const [liveDecryption, setLiveDecryption] = useState(localStorage.getItem('pear_live_decryption') === 'true');
|
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 = {
|
const defaultTheme = {
|
||||||
base: '#000000',
|
base: '#000000',
|
||||||
|
|
@ -171,11 +196,6 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
|
||||||
localStorage.setItem('pear_video_input', id);
|
localStorage.setItem('pear_video_input', id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAutoRestartToggle = (e) => {
|
|
||||||
setAutoRestart(e.target.checked);
|
|
||||||
localStorage.setItem('pear_auto_restart', e.target.checked);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleThemeChange = (key, val) => {
|
const handleThemeChange = (key, val) => {
|
||||||
const newTheme = { ...theme, [key]: val };
|
const newTheme = { ...theme, [key]: val };
|
||||||
setTheme(newTheme);
|
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.");
|
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 = () => {
|
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 (
|
return (
|
||||||
<div className="absolute inset-0 z-50 flex bg-base">
|
<div className="absolute inset-0 z-50 flex bg-base">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
|
|
@ -271,6 +300,18 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
|
||||||
>
|
>
|
||||||
Appearance
|
Appearance
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setActiveTab('voice')}
|
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'}`}
|
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>
|
<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="bg-surface rounded-lg p-4 mb-6">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-start gap-6">
|
||||||
<div
|
<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'}`}
|
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()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
|
@ -353,7 +394,7 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
|
||||||
type="text"
|
type="text"
|
||||||
value={tempUsername}
|
value={tempUsername}
|
||||||
onChange={(e) => setTempUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_.]/g, ''))}
|
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..."
|
placeholder="Set your username..."
|
||||||
maxLength={24}
|
maxLength={24}
|
||||||
/>
|
/>
|
||||||
|
|
@ -362,9 +403,18 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
|
||||||
type="text"
|
type="text"
|
||||||
value={'@' + profile.username}
|
value={'@' + profile.username}
|
||||||
readOnly
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -405,6 +455,26 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
<h2 className="text-xl font-bold text-text mb-6">Appearance</h2>
|
<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">
|
<div className="bg-surface rounded-lg p-6 mb-6">
|
||||||
<h3 className="text-muted uppercase text-xs font-bold mb-4">Theme Colors</h3>
|
<h3 className="text-muted uppercase text-xs font-bold mb-4">Theme Colors</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|
@ -430,6 +500,128 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
|
||||||
</div>
|
</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' && (
|
{activeTab === 'voice' && (
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
<h2 className="text-xl font-bold text-text mb-6">Voice & Video Settings</h2>
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="mb-6">
|
||||||
<label className="block text-xs font-bold text-muted uppercase mb-2">Camera (Webcam)</label>
|
<label className="block text-xs font-bold text-muted uppercase mb-2">Camera (Webcam)</label>
|
||||||
<select
|
<select
|
||||||
value={selectedVideoInput}
|
value={selectedVideoInput}
|
||||||
|
|
@ -482,6 +674,25 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
</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>
|
<h2 className="text-xl font-bold text-text mb-6">App Settings</h2>
|
||||||
|
|
||||||
<div className="bg-surface rounded-lg p-6 mb-6">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={liveDecryption}
|
checked={closeToTray}
|
||||||
onChange={(e) => {
|
onChange={handleTrayToggle}
|
||||||
setLiveDecryption(e.target.checked);
|
|
||||||
localStorage.setItem('pear_live_decryption', e.target.checked);
|
|
||||||
}}
|
|
||||||
className="w-5 h-5 accent-accent cursor-pointer"
|
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>
|
</div>
|
||||||
<p className="text-[10px] text-muted mt-1 ml-8">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -555,7 +763,10 @@ export default function ProfileSettingsModal({ profile, myKey, onClose, onSave,
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={autoRestart}
|
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"
|
className="w-5 h-5 accent-accent cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-text">Automatically restart to apply updates</span>
|
<span className="text-sm text-text">Automatically restart to apply updates</span>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,42 @@
|
||||||
import React, { useState, useRef } from 'react';
|
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 [serverName, setServerName] = useState(activeServerObj.name || '');
|
||||||
const [serverIcon, setServerIcon] = useState(activeServerObj.icon || null);
|
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 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 handleImageUpload = (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
@ -41,7 +71,7 @@ export default function ServerSettingsModal({ onClose, activeServerObj, onDelete
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (serverName.trim() === '') return;
|
if (serverName.trim() === '') return;
|
||||||
network.updateServerSettings(activeServerObj.topicHex, serverName.trim(), serverIcon, allowAnyone);
|
network.updateServerSettings(activeServerObj.topicHex, serverName.trim(), serverIcon, allowAnyone, channels, roles, memberRoles);
|
||||||
onClose();
|
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 (
|
return (
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/70" onClick={onClose}>
|
<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()}>
|
<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()}>
|
||||||
<h2 className="text-2xl font-bold text-center text-text mb-2">Hub Settings</h2>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-4 mt-2">
|
<div className="flex h-full">
|
||||||
<div
|
{/* Sidebar */}
|
||||||
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'}`}
|
<div className="w-48 bg-panel flex flex-col py-6 px-3 border-r border-surface shrink-0">
|
||||||
onClick={() => fileInputRef.current?.click()}
|
<h2 className="text-sm font-bold text-text mb-4 px-2 truncate">{activeServerObj.name}</h2>
|
||||||
>
|
<button
|
||||||
{serverIcon ? (
|
onClick={() => { setActiveTab('overview'); setEditingChannel(null); setEditingRole(null); }}
|
||||||
<img src={serverIcon} alt="hub icon" className="w-full h-full object-cover" />
|
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'}`}
|
||||||
) : (
|
>
|
||||||
<div className="text-center text-xs text-muted flex flex-col items-center gap-1">
|
Overview
|
||||||
<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>
|
</button>
|
||||||
Change
|
{canManageRoles && (
|
||||||
</div>
|
<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">
|
{canManageChannels && (
|
||||||
<span className="text-[10px] uppercase tracking-wider text-white">Upload</span>
|
<button
|
||||||
</div>
|
onClick={() => { setActiveTab('channels'); setEditingRole(null); }}
|
||||||
<input type="file" ref={fileInputRef} onChange={handleImageUpload} accept="image/png, image/jpeg" className="hidden" />
|
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>
|
||||||
|
|
||||||
<div className="w-full">
|
{/* Content */}
|
||||||
<label className="block text-xs font-bold text-muted uppercase mb-2 text-left">Hub Name</label>
|
<div className="flex-1 flex flex-col relative overflow-hidden">
|
||||||
<input
|
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||||
type="text"
|
|
||||||
value={serverName}
|
{activeTab === 'overview' && (
|
||||||
onChange={(e) => setServerName(e.target.value)}
|
<div className="flex flex-col gap-6">
|
||||||
className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-2 focus:ring-accent mb-4"
|
<h3 className="text-xl font-bold text-text">Server Overview</h3>
|
||||||
placeholder="e.g. My Cool Club"
|
|
||||||
maxLength={32}
|
<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>
|
</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">
|
<div className="p-4 bg-base flex justify-end gap-3 border-t border-surface shrink-0">
|
||||||
<h3 className="text-red-500 font-bold mb-2 uppercase text-xs">Danger Zone</h3>
|
<button onClick={onClose} className="text-text hover:underline text-sm font-medium px-4 py-2">
|
||||||
<button
|
Cancel
|
||||||
onClick={handleDelete}
|
</button>
|
||||||
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors w-full"
|
<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
|
||||||
Delete Hub
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 { generateIdentitySeed, network } from '../p2p/index.js';
|
||||||
import logo from '../../assets/icon.png';
|
import logo from '../../assets/icon.png';
|
||||||
|
|
||||||
|
|
@ -7,9 +7,10 @@ export default function SetupScreen({ setProfile }) {
|
||||||
const [savedAccounts, setSavedAccounts] = useState([]);
|
const [savedAccounts, setSavedAccounts] = useState([]);
|
||||||
|
|
||||||
const [displayName, setDisplayName] = useState('');
|
const [displayName, setDisplayName] = useState('');
|
||||||
const[username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [seedHex, setSeedHex] = useState('');
|
const [seedHex, setSeedHex] = useState('');
|
||||||
const [isChecking, setIsChecking] = useState(false);
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
const [seedAcknowledged, setSeedAcknowledged] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const accounts = JSON.parse(localStorage.getItem('pear_saved_accounts') || '[]');
|
const accounts = JSON.parse(localStorage.getItem('pear_saved_accounts') || '[]');
|
||||||
|
|
@ -30,7 +31,7 @@ export default function SetupScreen({ setProfile }) {
|
||||||
|
|
||||||
const handleSignup = async (e) => {
|
const handleSignup = async (e) => {
|
||||||
e.preventDefault();
|
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, '');
|
const cleanUsername = username.trim().toLowerCase().replace(/[^a-z0-9_.]/g, '');
|
||||||
if (!cleanUsername) return alert("Invalid username. Use only letters, numbers, underscores, and periods.");
|
if (!cleanUsername) return alert("Invalid username. Use only letters, numbers, underscores, and periods.");
|
||||||
|
|
@ -129,7 +130,7 @@ export default function SetupScreen({ setProfile }) {
|
||||||
disabled={isChecking}
|
disabled={isChecking}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-6">
|
<div className="mb-4">
|
||||||
<label className="block text-xs font-bold text-muted uppercase mb-2">Username</label>
|
<label className="block text-xs font-bold text-muted uppercase mb-2">Username</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -141,7 +142,20 @@ export default function SetupScreen({ setProfile }) {
|
||||||
disabled={isChecking}
|
disabled={isChecking}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 ? (
|
{isChecking ? (
|
||||||
<>
|
<>
|
||||||
<span className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
|
<span className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,68 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import logo from '../../assets/iconWhite.png';
|
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);
|
const[contextMenu, setContextMenu] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -12,37 +73,13 @@ export default function Sidebar({ activeView, setActiveView, servers, myKey, onO
|
||||||
|
|
||||||
const publicServers = servers.filter(s => s.isGroupChat !== true);
|
const publicServers = servers.filter(s => s.isGroupChat !== true);
|
||||||
|
|
||||||
const NavItem = ({ id, icon, name, isImage, imageClass, onClick, onContextMenu }) => {
|
// Calculate total unread DMs and Group Chats for the Whispers badge
|
||||||
const isActive = activeView === id;
|
let dmUnreadCount = 0;
|
||||||
return (
|
Object.entries(unreadCounts).forEach(([key, count]) => {
|
||||||
<div className="relative group flex justify-center w-full mb-2">
|
if (!key.includes('-') || key.length === 64) {
|
||||||
<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>
|
dmUnreadCount += count;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
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">
|
<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"
|
id="dms"
|
||||||
name="Whispers"
|
name="Whispers"
|
||||||
isImage={true}
|
isImage={true}
|
||||||
|
isServerImage={false}
|
||||||
icon={logo}
|
icon={logo}
|
||||||
imageClass="w-7 h-7 object-contain"
|
imageClass="w-7 h-7 object-contain"
|
||||||
onClick={() => setActiveView('dms')}
|
onClick={() => setActiveView('dms')}
|
||||||
|
badgeCount={dmUnreadCount}
|
||||||
|
isActive={activeView === 'dms'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-8 h-[2px] bg-surface rounded-full my-2 shrink-0"></div>
|
<div className="w-8 h-[2px] bg-surface rounded-full my-2 shrink-0"></div>
|
||||||
|
|
||||||
{publicServers.map(server => (
|
{publicServers.map(server => {
|
||||||
<NavItem
|
let serverUnreadCount = 0;
|
||||||
key={server.topicHex}
|
Object.entries(unreadCounts).forEach(([key, count]) => {
|
||||||
id={server.topicHex}
|
if (key.startsWith(server.topicHex + '-')) {
|
||||||
name={server.name}
|
serverUnreadCount += count;
|
||||||
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 });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div
|
return (
|
||||||
onClick={onOpenCreateServer}
|
<NavItem
|
||||||
className="relative group flex justify-center w-full mt-2"
|
key={server.topicHex}
|
||||||
>
|
id={server.topicHex}
|
||||||
<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">
|
name={server.name}
|
||||||
<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>
|
isImage={!!server.icon}
|
||||||
</div>
|
isServerImage={!!server.icon}
|
||||||
|
icon={server.icon || server.name.substring(0, 2).toUpperCase()}
|
||||||
{/* Discord-style Tooltip */}
|
onClick={() => setActiveView(server.topicHex)}
|
||||||
<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">
|
hasUnread={serverUnreadCount > 0}
|
||||||
<div className="w-0 h-0 border-y-[6px] border-y-transparent border-r-[6px] border-r-panel -mr-[1px]"></div>
|
badgeCount={serverUnreadCount}
|
||||||
<div className="bg-panel text-text text-[15px] font-bold py-1.5 px-3 rounded-md shadow-xl whitespace-nowrap">
|
isActive={activeView === server.topicHex}
|
||||||
Create Hub
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
71
Peercord Source/src/components/UserProfileModal.jsx
Normal file
71
Peercord Source/src/components/UserProfileModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -32,9 +32,18 @@ async function handleIdentity(network, peerKey, parsed) {
|
||||||
peerInfo.displayName = parsed.displayName;
|
peerInfo.displayName = parsed.displayName;
|
||||||
peerInfo.username = parsed.username;
|
peerInfo.username = parsed.username;
|
||||||
peerInfo.avatar = parsed.avatar;
|
peerInfo.avatar = parsed.avatar;
|
||||||
|
peerInfo.bio = parsed.bio || '';
|
||||||
|
peerInfo.connections = parsed.connections || [];
|
||||||
peerInfo.coreKey = parsed.coreKey;
|
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);
|
network.knownProfiles.set(peerKey, profileObj);
|
||||||
if (network.profilesDb) await network.profilesDb.put(peerKey, profileObj);
|
if (network.profilesDb) await network.profilesDb.put(peerKey, profileObj);
|
||||||
if (network.coresDb && parsed.coreKey) await network.coresDb.put(peerKey, parsed.coreKey);
|
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) {
|
if (parsed.username) {
|
||||||
const uname = parsed.username.toLowerCase();
|
const uname = parsed.username.toLowerCase();
|
||||||
network.userDirectory.set(uname, { pubKey: peerKey, profile: parsed });
|
network.userDirectory.set(uname, { pubKey: peerKey, profile: profileObj });
|
||||||
network.dirDb.put(uname, { pubKey: peerKey, profile: parsed });
|
network.dirDb.put(uname, { pubKey: peerKey, profile: profileObj });
|
||||||
network._checkPendingRequests(uname, peerKey, parsed);
|
network._checkPendingRequests(uname, peerKey, profileObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (network.dms[peerKey]) {
|
if (network.dms[peerKey]) {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ const b4a = window.require('b4a');
|
||||||
import { generateUUID, Hyperswarm, Corestore, Hyperbee, sodium, fs, os, path, http } from './utils.js';
|
import { generateUUID, Hyperswarm, Corestore, Hyperbee, sodium, fs, os, path, http } from './utils.js';
|
||||||
import * as Identity from './modules/identity.js';
|
import * as Identity from './modules/identity.js';
|
||||||
import { handleData } from './handlers.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 { createServer, joinServer, deleteServer, leaveServer, sendServerInvite, updateServerSettings, sendGroupChatAdd } from './modules/servers.js';
|
||||||
import { searchUser, queueFriendRequest, trackPeerCore } from './modules/discovery.js';
|
import { searchUser, queueFriendRequest, trackPeerCore } from './modules/discovery.js';
|
||||||
import { sendFile, sendDMFile, downloadFile } from './modules/files.js';
|
import { sendFile, sendDMFile, downloadFile } from './modules/files.js';
|
||||||
|
|
@ -27,6 +27,8 @@ class P2PNetwork {
|
||||||
this.displayName = '';
|
this.displayName = '';
|
||||||
this.username = '';
|
this.username = '';
|
||||||
this.avatar = null;
|
this.avatar = null;
|
||||||
|
this.bio = '';
|
||||||
|
this.connections = [];
|
||||||
this.storagePath = null;
|
this.storagePath = null;
|
||||||
|
|
||||||
this.peers = new Map();
|
this.peers = new Map();
|
||||||
|
|
@ -52,7 +54,7 @@ class P2PNetwork {
|
||||||
this.logicalClock = 0;
|
this.logicalClock = 0;
|
||||||
this.timeOffset = 0;
|
this.timeOffset = 0;
|
||||||
|
|
||||||
// App State Tracking (Used to prevent auto-restarts during critical operations)
|
// App State Tracking
|
||||||
this.activeCalls = 0;
|
this.activeCalls = 0;
|
||||||
|
|
||||||
this.onInit = null;
|
this.onInit = null;
|
||||||
|
|
@ -72,10 +74,11 @@ class P2PNetwork {
|
||||||
processMessage = (msg) => processMessage(this, msg);
|
processMessage = (msg) => processMessage(this, msg);
|
||||||
sendDMRequest = (targetKey, profile) => sendDMRequest(this, targetKey, profile);
|
sendDMRequest = (targetKey, profile) => sendDMRequest(this, targetKey, profile);
|
||||||
acceptDMRequest = (targetKey) => acceptDMRequest(this, targetKey);
|
acceptDMRequest = (targetKey) => acceptDMRequest(this, targetKey);
|
||||||
sendMessage = (channel, text) => sendMessage(this, channel, text);
|
sendMessage = (channel, text, replyTo) => sendMessage(this, channel, text, replyTo);
|
||||||
sendDM = (targetKey, text) => sendDM(this, targetKey, text);
|
sendDM = (targetKey, text, replyTo) => sendDM(this, targetKey, text, replyTo);
|
||||||
sendEditMessage = (targetId, newText) => sendEditMessage(this, targetId, newText);
|
sendEditMessage = (targetId, newText) => sendEditMessage(this, targetId, newText);
|
||||||
sendDeleteMessage = (targetId) => sendDeleteMessage(this, targetId);
|
sendDeleteMessage = (targetId) => sendDeleteMessage(this, targetId);
|
||||||
|
sendReaction = (targetId, emoji, isDM, targetKey) => sendReaction(this, targetId, emoji, isDM, targetKey);
|
||||||
sendEphemeral = (payload) => sendEphemeral(this, payload);
|
sendEphemeral = (payload) => sendEphemeral(this, payload);
|
||||||
sendReadReceipt = (channel, messageId) => sendReadReceipt(this, channel, messageId);
|
sendReadReceipt = (channel, messageId) => sendReadReceipt(this, channel, messageId);
|
||||||
sendDeliveredReceipt = (channel, messageId) => sendDeliveredReceipt(this, channel, messageId);
|
sendDeliveredReceipt = (channel, messageId) => sendDeliveredReceipt(this, channel, messageId);
|
||||||
|
|
@ -103,7 +106,6 @@ class P2PNetwork {
|
||||||
if (this.serverDb) await this.serverDb.del(topicHex);
|
if (this.serverDb) await this.serverDb.del(topicHex);
|
||||||
delete this.serverMembers[topicHex];
|
delete this.serverMembers[topicHex];
|
||||||
|
|
||||||
// Remove message history for this server/group chat
|
|
||||||
const msgsToDelete =[];
|
const msgsToDelete =[];
|
||||||
for (const [msgId, msg] of this.messages.entries()) {
|
for (const [msgId, msg] of this.messages.entries()) {
|
||||||
const ch = msg.payload?.channel;
|
const ch = msg.payload?.channel;
|
||||||
|
|
@ -152,7 +154,6 @@ class P2PNetwork {
|
||||||
this._emitSync();
|
this._emitSync();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Using spread arguments ensures all flags (like isGroupChat) are properly passed down
|
|
||||||
createServer = (...args) => createServer(this, ...args);
|
createServer = (...args) => createServer(this, ...args);
|
||||||
joinServer = (...args) => joinServer(this, ...args);
|
joinServer = (...args) => joinServer(this, ...args);
|
||||||
deleteServer = (...args) => deleteServer(this, ...args);
|
deleteServer = (...args) => deleteServer(this, ...args);
|
||||||
|
|
@ -172,7 +173,8 @@ class P2PNetwork {
|
||||||
removeWebRTCListener = (fn) => removeWebRTCListener(this, fn);
|
removeWebRTCListener = (fn) => removeWebRTCListener(this, fn);
|
||||||
sendWebRTCSignal = (target, payload) => sendWebRTCSignal(this, target, payload);
|
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) => {
|
_checkPendingRequests = (uname, pubKey, profile) => {
|
||||||
if (this.pendingFriendRequests.has(uname)) {
|
if (this.pendingFriendRequests.has(uname)) {
|
||||||
this.pendingFriendRequests.delete(uname);
|
this.pendingFriendRequests.delete(uname);
|
||||||
|
|
@ -246,7 +248,6 @@ class P2PNetwork {
|
||||||
if (!http) throw new Error("HTTP module not loaded");
|
if (!http) throw new Error("HTTP module not loaded");
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
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({
|
const req = http.request({
|
||||||
hostname: '1.1.1.1',
|
hostname: '1.1.1.1',
|
||||||
method: 'HEAD',
|
method: 'HEAD',
|
||||||
|
|
@ -284,7 +285,6 @@ class P2PNetwork {
|
||||||
|
|
||||||
async checkUsernameAvailable(username) {
|
async checkUsernameAvailable(username) {
|
||||||
const normalized = username.toLowerCase();
|
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 tempSwarm = new Hyperswarm({ maxPeers: 3, maxClientConnections: 3, maxServerConnections: 0 });
|
||||||
const topic = b4a.alloc(32);
|
const topic = b4a.alloc(32);
|
||||||
sodium.crypto_generichash(topic, b4a.from('peercord-user:' + normalized));
|
sodium.crypto_generichash(topic, b4a.from('peercord-user:' + normalized));
|
||||||
|
|
@ -297,7 +297,6 @@ class P2PNetwork {
|
||||||
|
|
||||||
tempSwarm.join(topic, { client: true, server: false });
|
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++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
if (isTaken) break;
|
if (isTaken) break;
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
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.displayName = displayName;
|
||||||
this.username = (username || 'unknown').toLowerCase();
|
this.username = (username || 'unknown').toLowerCase();
|
||||||
this.avatar = avatar;
|
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(() => {});
|
this._syncTimeWithServer().catch(() => {});
|
||||||
|
|
||||||
let instanceId = 'default';
|
let instanceId = 'default';
|
||||||
|
|
@ -391,15 +391,14 @@ class P2PNetwork {
|
||||||
sodium.crypto_sign_seed_keypair(publicKey, secretKey, seed);
|
sodium.crypto_sign_seed_keypair(publicKey, secretKey, seed);
|
||||||
this.myKey = b4a.toString(publicKey, 'hex');
|
this.myKey = b4a.toString(publicKey, 'hex');
|
||||||
this.secretKey = secretKey;
|
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 = new Hyperswarm({ keyPair: { publicKey, secretKey } });
|
||||||
this.swarm.on('connection', (conn, info) => {
|
this.swarm.on('connection', (conn, info) => {
|
||||||
this.store.replicate(conn);
|
this.store.replicate(conn);
|
||||||
const peerKey = b4a.toString(info.publicKey, 'hex');
|
const peerKey = b4a.toString(info.publicKey, 'hex');
|
||||||
this.peers.set(peerKey, { conn, displayName: 'Unknown', username: 'unknown', avatar: null, coreKey: null });
|
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, coreKey: this.coreKey });
|
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));
|
conn.write(b4a.from(identityMsg));
|
||||||
if (this.onPeerUpdate) this.onPeerUpdate(this.getPeerList());
|
if (this.onPeerUpdate) this.onPeerUpdate(this.getPeerList());
|
||||||
conn.on('data', async (data) => handleData(this, peerKey, data, conn));
|
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));
|
const paceJoin = () => new Promise(resolve => setTimeout(resolve, 100));
|
||||||
let joinCount = 0;
|
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) {
|
for (const server of this.servers) {
|
||||||
await this._joinTopic(server.topicHex, true); // Skip flush inside the method
|
await this._joinTopic(server.topicHex, true);
|
||||||
joinCount++;
|
joinCount++;
|
||||||
|
|
||||||
// Batch flush every 5 topics to let the router's NAT table breathe
|
|
||||||
if (joinCount % 5 === 0) {
|
if (joinCount % 5 === 0) {
|
||||||
try { await this.swarm.flush(); } catch(e) {}
|
try { await this.swarm.flush(); } catch(e) {}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -446,29 +442,24 @@ class P2PNetwork {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Join global updates topic for instant OTA broadcasts
|
|
||||||
const globalUpdateTopic = b4a.alloc(32);
|
const globalUpdateTopic = b4a.alloc(32);
|
||||||
sodium.crypto_generichash(globalUpdateTopic, b4a.from('peercord-global-updates'));
|
sodium.crypto_generichash(globalUpdateTopic, b4a.from('peercord-global-updates'));
|
||||||
this.swarm.join(globalUpdateTopic, { client: true, server: true });
|
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)); }
|
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();
|
this._emitKnownProfiles();
|
||||||
if (this.onInit) this.onInit(this.myKey);
|
if (this.onInit) this.onInit(this.myKey);
|
||||||
if (this.onDMsUpdate) this.onDMsUpdate({ ...this.dms });
|
if (this.onDMsUpdate) this.onDMsUpdate({ ...this.dms });
|
||||||
this._emitServers();
|
this._emitServers();
|
||||||
this._emitMessages();
|
this._emitMessages();
|
||||||
|
|
||||||
// 5. LOAD PEER CORES
|
|
||||||
const corePromises =[];
|
const corePromises =[];
|
||||||
for await (const { key, value } of this.coresDb.createReadStream()) {
|
for await (const { key, value } of this.coresDb.createReadStream()) {
|
||||||
corePromises.push(this.trackPeerCore(value));
|
corePromises.push(this.trackPeerCore(value));
|
||||||
}
|
}
|
||||||
await Promise.all(corePromises);
|
await Promise.all(corePromises);
|
||||||
|
|
||||||
// 6. FLUSH SWARM (Final flush for any remaining un-flushed topics)
|
|
||||||
this.swarm.flush().then(() => {
|
this.swarm.flush().then(() => {
|
||||||
console.log("[P2P] Swarm flushed and announced.");
|
console.log("[P2P] Swarm flushed and announced.");
|
||||||
}).catch(err => console.warn("[P2P] Swarm flush failed (offline?):", err));
|
}).catch(err => console.warn("[P2P] Swarm flush failed (offline?):", err));
|
||||||
|
|
@ -476,7 +467,7 @@ class P2PNetwork {
|
||||||
|
|
||||||
getPeerList() {
|
getPeerList() {
|
||||||
return Array.from(this.peers.entries()).map(([key, info]) => ({
|
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,
|
total: 0,
|
||||||
dms: {},
|
dms: {},
|
||||||
servers: {},
|
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()) {
|
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;
|
const size = msg.payload.file.size || 0;
|
||||||
stats.total += size;
|
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 = {
|
const fileInfo = {
|
||||||
id: msg.payload.id,
|
id: msg.payload.id,
|
||||||
name: msg.payload.file.name,
|
name: msg.payload.file.name,
|
||||||
size: size,
|
size: size,
|
||||||
coreKey: msg.payload.file.coreKey,
|
coreKey: msg.payload.file.coreKey,
|
||||||
timestamp: msg.payload.timestamp,
|
timestamp: msg.payload.timestamp,
|
||||||
channel: msg.channel,
|
channel: msg.payload.channel,
|
||||||
recipient: msg.recipient
|
recipient: msg.recipient,
|
||||||
|
sender: msg.sender,
|
||||||
|
target: target,
|
||||||
|
serverName: serverName,
|
||||||
|
isGroupChat: isGroupChat
|
||||||
};
|
};
|
||||||
stats.files.push(fileInfo);
|
stats.files.push(fileInfo);
|
||||||
|
|
||||||
if (msg.recipient) {
|
if (msg.recipient) {
|
||||||
const target = msg.sender === this.myKey ? msg.recipient : msg.sender;
|
|
||||||
stats.dms[target] = (stats.dms[target] || 0) + size;
|
stats.dms[target] = (stats.dms[target] || 0) + size;
|
||||||
} else {
|
} else if (msg.payload.channel) {
|
||||||
const topicHex = msg.channel.substring(0, 64);
|
const topicHex = msg.payload.channel.substring(0, 64);
|
||||||
const channelName = msg.channel.substring(65);
|
const channelName = msg.payload.channel.substring(65) || 'general';
|
||||||
if (!stats.servers[topicHex]) stats.servers[topicHex] = { total: 0, channels: {} };
|
if (!stats.servers[topicHex]) stats.servers[topicHex] = { total: 0, channels: {}, name: serverName, isGroupChat };
|
||||||
stats.servers[topicHex].total += size;
|
stats.servers[topicHex].total += size;
|
||||||
stats.servers[topicHex].channels[channelName] = (stats.servers[topicHex].channels[channelName] || 0) + size;
|
stats.servers[topicHex].channels[channelName] = (stats.servers[topicHex].channels[channelName] || 0) + size;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ export async function searchUser(network, targetUsername) {
|
||||||
return network.userDirectory.get(normalized);
|
return network.userDirectory.get(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Join the DHT topic to reliably find the user even if they aren't in our current peer list
|
|
||||||
const topic = b4a.alloc(32);
|
const topic = b4a.alloc(32);
|
||||||
sodium.crypto_generichash(topic, b4a.from('peercord-user:' + normalized));
|
sodium.crypto_generichash(topic, b4a.from('peercord-user:' + normalized));
|
||||||
network.swarm.join(topic, { client: true, server: false });
|
network.swarm.join(topic, { client: true, server: false });
|
||||||
|
|
@ -27,7 +26,6 @@ export async function searchUser(network, targetUsername) {
|
||||||
finish(null);
|
finish(null);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
// Check periodically if they appeared in userDirectory after connecting
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (network.userDirectory.has(normalized)) {
|
if (network.userDirectory.has(normalized)) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
@ -36,7 +34,6 @@ export async function searchUser(network, targetUsername) {
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
// Also broadcast whois to existing peers just in case
|
|
||||||
const queryId = generateUUID();
|
const queryId = generateUUID();
|
||||||
network.pendingWhois.set(queryId, (result) => {
|
network.pendingWhois.set(queryId, (result) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
@ -69,20 +66,33 @@ export async function trackPeerCore(network, coreKeyHex) {
|
||||||
|
|
||||||
let processedSeq = -1;
|
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++) {
|
for (let i = 0; i < core.length; i++) {
|
||||||
const msg = await core.get(i);
|
if (core.has(i)) {
|
||||||
network.processMessage(msg);
|
const msg = await core.get(i);
|
||||||
processedSeq = 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 () => {
|
core.on('append', async () => {
|
||||||
network._emitSync();
|
network._emitSync();
|
||||||
for (let i = processedSeq + 1; i < core.length; i++) {
|
for (let i = processedSeq + 1; i < core.length; i++) {
|
||||||
const msg = await core.get(i);
|
if (core.has(i)) {
|
||||||
network.processMessage(msg);
|
const msg = await core.get(i);
|
||||||
processedSeq = 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 });
|
||||||
}
|
}
|
||||||
|
|
@ -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.displayName = displayName;
|
||||||
network.avatar = avatar;
|
network.avatar = avatar;
|
||||||
|
network.bio = bio;
|
||||||
|
network.connections = connections;
|
||||||
|
|
||||||
if (username && username !== 'unknown' && network.username === 'unknown') {
|
if (username && username !== 'unknown' && network.username === 'unknown') {
|
||||||
network.username = username;
|
network.username = username;
|
||||||
|
|
@ -52,13 +54,13 @@ export function updateProfile(network, displayName, avatar, username) {
|
||||||
network.swarm.join(myTopic, { client: false, server: true });
|
network.swarm.join(myTopic, { client: false, server: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
network.knownProfiles.set(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 });
|
if (network.profilesDb) network.profilesDb.put(network.myKey, { displayName, username: network.username, avatar, bio, connections });
|
||||||
network._emitKnownProfiles();
|
network._emitKnownProfiles();
|
||||||
|
|
||||||
if (!network.swarm) return;
|
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);
|
const payload = b4a.from(identityMsg);
|
||||||
for (const { conn } of network.peers.values()) conn.write(payload);
|
for (const { conn } of network.peers.values()) conn.write(payload);
|
||||||
network._emitMessages();
|
network._emitMessages();
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,31 @@ export function getAllMessages(network) {
|
||||||
const ch = m.payload.channel;
|
const ch = m.payload.channel;
|
||||||
if (ch && ch.length > 64 && ch[64] === '-') {
|
if (ch && ch.length > 64 && ch[64] === '-') {
|
||||||
const topicHex = ch.substring(0, 64);
|
const topicHex = ch.substring(0, 64);
|
||||||
|
const chName = ch.substring(65);
|
||||||
if (!joinedTopics.has(topicHex)) return false;
|
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;
|
return true;
|
||||||
}).map(m => {
|
}).map(m => {
|
||||||
|
|
@ -29,10 +53,11 @@ export function getAllMessages(network) {
|
||||||
timestamp: m.payload.timestamp,
|
timestamp: m.payload.timestamp,
|
||||||
logicalTime: m.payload.logicalTime || 0,
|
logicalTime: m.payload.logicalTime || 0,
|
||||||
edited: m.payload.edited || false,
|
edited: m.payload.edited || false,
|
||||||
|
replyTo: m.payload.replyTo || null,
|
||||||
|
reactions: m.reactions || {},
|
||||||
sender: m.sender,
|
sender: m.sender,
|
||||||
senderName: known ? known.displayName : 'Unknown',
|
senderName: known ? known.displayName : 'Unknown',
|
||||||
senderAvatar: known ? known.avatar : null,
|
senderAvatar: known ? known.avatar : null,
|
||||||
// Pass the raw crypto data to the UI for verification
|
|
||||||
isEncrypted: !!m.cipher,
|
isEncrypted: !!m.cipher,
|
||||||
cipher: m.cipher || null,
|
cipher: m.cipher || null,
|
||||||
nonce: m.nonce || null
|
nonce: m.nonce || null
|
||||||
|
|
@ -84,6 +109,24 @@ export async function processMessage(network, msg) {
|
||||||
return;
|
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 (msg.payload.type === 'dm_request' && msg.sender !== network.myKey) {
|
||||||
if (!network.dms[msg.sender]) {
|
if (!network.dms[msg.sender]) {
|
||||||
network.dms[msg.sender] = { status: 'pending_incoming', profile: msg.payload.profile };
|
network.dms[msg.sender] = { status: 'pending_incoming', profile: msg.payload.profile };
|
||||||
|
|
@ -124,7 +167,7 @@ export async function processMessage(network, msg) {
|
||||||
}
|
}
|
||||||
} catch (err) { return; }
|
} 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') {
|
if (type === 'server_delete') {
|
||||||
const server = network.servers.find(s => s.topicHex === serverTopicHex);
|
const server = network.servers.find(s => s.topicHex === serverTopicHex);
|
||||||
|
|
@ -135,10 +178,32 @@ export async function processMessage(network, msg) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'server_leave') {
|
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]) {
|
if (network.serverMembers[serverTopicHex]) {
|
||||||
network.serverMembers[serverTopicHex].delete(msg.sender);
|
network.serverMembers[serverTopicHex].delete(targetUser);
|
||||||
network._emitServerMembers();
|
network._emitServerMembers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (targetUser === network.myKey) {
|
||||||
|
network._wipeLocalServerData(serverTopicHex);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,20 +216,124 @@ export async function processMessage(network, msg) {
|
||||||
|
|
||||||
if (type === 'server_settings_update') {
|
if (type === 'server_settings_update') {
|
||||||
const server = network.servers.find(s => s.topicHex === serverTopicHex);
|
const server = network.servers.find(s => s.topicHex === serverTopicHex);
|
||||||
if (server && msg.sender === server.owner) {
|
if (server) {
|
||||||
if (allowAnyoneToInvite !== undefined) server.allowAnyoneToInvite = allowAnyoneToInvite;
|
let canUpdateSettings = false;
|
||||||
if (name !== undefined) server.name = name;
|
let canManageChannels = false;
|
||||||
if (icon !== undefined) server.icon = icon;
|
let canManageRoles = false;
|
||||||
if (channels !== undefined) server.channels = channels;
|
|
||||||
|
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);
|
const idx = targetMsg.reactions[emoji].indexOf(msg.sender);
|
||||||
network._emitServers();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'delete') {
|
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.deletedMessages.add(targetId);
|
||||||
network.messages.delete(targetId);
|
network.messages.delete(targetId);
|
||||||
network._emitMessages();
|
network._emitMessages();
|
||||||
|
|
@ -183,7 +352,53 @@ export async function processMessage(network, msg) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'chat' || type === 'file') {
|
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.messages.set(id, msg);
|
||||||
network._emitMessages();
|
network._emitMessages();
|
||||||
|
|
||||||
|
|
@ -243,7 +458,7 @@ export async function sendDMRequest(network, targetKey, profile) {
|
||||||
network.dms[targetKey] = { status: 'pending_outgoing', profile };
|
network.dms[targetKey] = { status: 'pending_outgoing', profile };
|
||||||
await network.db.put('dm:' + targetKey, network.dms[targetKey]);
|
await network.db.put('dm:' + targetKey, network.dms[targetKey]);
|
||||||
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
|
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) {
|
export async function acceptDMRequest(network, targetKey) {
|
||||||
|
|
@ -255,10 +470,25 @@ export async function acceptDMRequest(network, targetKey) {
|
||||||
await _appendEncryptedMessage(network, targetKey, { type: 'dm_accept' });
|
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 sendMessage(network, channel, text, replyTo = null) {
|
||||||
export async function sendDM(network, targetKey, text) { await _appendEncryptedMessage(network, targetKey, { type: 'dm_chat', id: generateUUID(), text }); }
|
await _appendSignedMessage(network, { type: 'chat', id: generateUUID(), channel, 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 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) {
|
export function sendEphemeral(network, payload) {
|
||||||
if (!network.swarm) return;
|
if (!network.swarm) return;
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,26 @@ export async function createServer(network, name, icon, allowAnyoneToInvite, isG
|
||||||
sodium.randombytes_buf(topic);
|
sodium.randombytes_buf(topic);
|
||||||
const topicHex = b4a.toString(topic, 'hex');
|
const topicHex = b4a.toString(topic, 'hex');
|
||||||
|
|
||||||
const channels = { text: ['general-chat'], voice: ['general-voice'] };
|
const channels = { text: ['general-chat'], voice: ['general-voice'], permissions: {}, send_permissions: {} };
|
||||||
const serverInfo = { name, icon, owner: network.myKey, allowAnyoneToInvite, isGroupChat, channels };
|
|
||||||
|
// 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.servers.push({ topicHex, ...serverInfo });
|
||||||
network._emitServers();
|
network._emitServers();
|
||||||
|
|
@ -20,10 +38,19 @@ export async function createServer(network, name, icon, allowAnyoneToInvite, isG
|
||||||
return { topicHex, ...serverInfo };
|
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;
|
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.servers.push({ topicHex, ...serverInfo });
|
||||||
network._emitServers();
|
network._emitServers();
|
||||||
|
|
@ -61,7 +88,9 @@ export async function sendServerInvite(network, targetKey, serverTopicHex) {
|
||||||
serverOwner: server.owner,
|
serverOwner: server.owner,
|
||||||
allowAnyoneToInvite: server.allowAnyoneToInvite,
|
allowAnyoneToInvite: server.allowAnyoneToInvite,
|
||||||
isGroupChat: server.isGroupChat,
|
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,
|
name: server.name,
|
||||||
icon: server.icon,
|
icon: server.icon,
|
||||||
owner: server.owner,
|
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({
|
await network._appendSignedMessage({
|
||||||
type: 'server_settings_update',
|
type: 'server_settings_update',
|
||||||
serverTopicHex,
|
serverTopicHex,
|
||||||
|
|
@ -89,6 +120,8 @@ export async function updateServerSettings(network, serverTopicHex, name, icon,
|
||||||
icon,
|
icon,
|
||||||
allowAnyoneToInvite,
|
allowAnyoneToInvite,
|
||||||
channels,
|
channels,
|
||||||
|
roles,
|
||||||
|
memberRoles,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -98,6 +131,8 @@ export async function updateServerSettings(network, serverTopicHex, name, icon,
|
||||||
if (icon !== undefined) server.icon = icon;
|
if (icon !== undefined) server.icon = icon;
|
||||||
if (allowAnyoneToInvite !== undefined) server.allowAnyoneToInvite = allowAnyoneToInvite;
|
if (allowAnyoneToInvite !== undefined) server.allowAnyoneToInvite = allowAnyoneToInvite;
|
||||||
if (channels !== undefined) server.channels = channels;
|
if (channels !== undefined) server.channels = channels;
|
||||||
|
if (roles !== undefined) server.roles = roles;
|
||||||
|
if (memberRoles !== undefined) server.memberRoles = memberRoles;
|
||||||
await network.serverDb.put(serverTopicHex, server);
|
await network.serverDb.put(serverTopicHex, server);
|
||||||
network._emitServers();
|
network._emitServers();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user