revert first commit
This commit is contained in:
2025-12-31 03:50:46 -05:00
parent 6499e745d7
commit 8d0efde1cc
15 changed files with 0 additions and 6201 deletions
-3
View File
@@ -1,3 +0,0 @@
db
spec
drives
-226
View File
@@ -1,226 +0,0 @@
{
"name": "Peer Chat",
"version": "1.8.0",
"domain": "peer.chat",
"enabled": true,
"description": "Global, decentralized real-time chatroom for all P2NS peers",
"author": "P2NS",
"homepage": "https://github.com/p2ns/p2ns",
"license": "MIT",
"icon": "message-circle",
"dependencies": {},
"www": "www",
"hyperdb": {
"schemas": {
"namespace": "peerchat",
"structs": [
{
"name": "message",
"compact": true,
"fields": [
{
"name": "id",
"type": "string",
"required": true
},
{
"name": "peerId",
"type": "string",
"required": true
},
{
"name": "displayName",
"type": "string"
},
{
"name": "avatar",
"type": "string"
},
{
"name": "text",
"type": "string",
"required": true
},
{
"name": "replyTo",
"type": "string"
},
{
"name": "attachments",
"type": "string"
},
{
"name": "editedAt",
"type": "uint"
},
{
"name": "deletedAt",
"type": "uint"
},
{
"name": "createdAt",
"type": "uint",
"required": true
}
]
},
{
"name": "reaction",
"compact": true,
"fields": [
{
"name": "messageId",
"type": "string",
"required": true
},
{
"name": "peerId",
"type": "string",
"required": true
},
{
"name": "emoji",
"type": "string",
"required": true
},
{
"name": "createdAt",
"type": "uint",
"required": true
}
]
},
{
"name": "presence",
"compact": true,
"fields": [
{
"name": "peerId",
"type": "string",
"required": true
},
{
"name": "lastSeen",
"type": "uint",
"required": true
},
{
"name": "typing",
"type": "bool"
}
]
},
{
"name": "file",
"compact": true,
"fields": [
{
"name": "id",
"type": "string",
"required": true
},
{
"name": "messageId",
"type": "string"
},
{
"name": "peerId",
"type": "string",
"required": true
},
{
"name": "filename",
"type": "string",
"required": true
},
{
"name": "mimeType",
"type": "string",
"required": true
},
{
"name": "size",
"type": "uint",
"required": true
},
{
"name": "driveKey",
"type": "string",
"required": true
},
{
"name": "embedHtml",
"type": "string"
},
{
"name": "createdAt",
"type": "uint",
"required": true
}
]
}
]
},
"collections": [
{
"name": "messages",
"schema": "@peerchat/message",
"key": [
"id"
]
},
{
"name": "reactions",
"schema": "@peerchat/reaction",
"key": [
"messageId",
"peerId"
]
},
{
"name": "presence",
"schema": "@peerchat/presence",
"key": [
"peerId"
]
},
{
"name": "files",
"schema": "@peerchat/file",
"key": [
"id"
]
}
],
"indexes": [
{
"name": "messages-by-createdAt",
"collection": "@peerchat/message",
"unique": false,
"key": {
"type": "uint",
"map": "mapCreatedAt"
}
},
{
"name": "reactions-by-messageId",
"collection": "@peerchat/reaction",
"unique": false,
"key": {
"type": "string",
"map": "mapMessageId"
}
},
{
"name": "presence-by-lastSeen",
"collection": "@peerchat/presence",
"unique": false,
"key": {
"type": "uint",
"map": "mapLastSeen"
}
}
],
"helpers": "./helpers.js"
}
}
-36
View File
@@ -1,36 +0,0 @@
/**
* Helper functions for Peer Chat HyperDB indexes
*/
/**
* Map createdAt timestamp for chronological message ordering
* @param {Object} record - Message record
* @param {Object} context - Context object
* @returns {Array<number>} Array of timestamps
*/
exports.mapCreatedAt = (record, context) => {
if (!record || !record.createdAt) return [];
return [record.createdAt];
};
/**
* Map messageId for reaction lookup
* @param {Object} record - Reaction record
* @param {Object} context - Context object
* @returns {Array<string>} Array of message IDs
*/
exports.mapMessageId = (record, context) => {
if (!record || !record.messageId) return [];
return [record.messageId];
};
/**
* Map lastSeen timestamp for presence ordering
* @param {Object} record - Presence record
* @param {Object} context - Context object
* @returns {Array<number>} Array of timestamps
*/
exports.mapLastSeen = (record, context) => {
if (!record || !record.lastSeen) return [];
return [record.lastSeen];
};
File diff suppressed because it is too large Load Diff
-65
View File
@@ -1,65 +0,0 @@
/**
* Multipart Form Data Parser
* Parses multipart/form-data request bodies
*/
/**
* Parse multipart/form-data
* @param {Buffer} buffer - Request body buffer
* @param {string} boundary - Multipart boundary
* @returns {Array} Array of parsed parts
*/
function parseMultipart(buffer, boundary) {
const parts = [];
const boundaryBuffer = Buffer.from(`--${boundary}`);
const endBoundaryBuffer = Buffer.from(`--${boundary}--`);
let start = 0;
let end = buffer.indexOf(boundaryBuffer, start);
while (end !== -1) {
const partStart = end + boundaryBuffer.length;
const nextBoundary = buffer.indexOf(boundaryBuffer, partStart);
const partEnd = nextBoundary === -1 ? buffer.indexOf(endBoundaryBuffer, partStart) : nextBoundary;
if (partEnd === -1) break;
const partBuffer = buffer.slice(partStart, partEnd);
const headerEnd = partBuffer.indexOf(Buffer.from('\r\n\r\n'));
if (headerEnd === -1) {
start = partEnd;
end = buffer.indexOf(boundaryBuffer, start);
continue;
}
const headers = partBuffer.slice(0, headerEnd).toString('utf8');
const body = partBuffer.slice(headerEnd + 4);
// Parse headers
const contentDisposition = headers.match(/Content-Disposition:\s*form-data;\s*name="([^"]+)";\s*filename="([^"]+)"/i);
const contentType = headers.match(/Content-Type:\s*([^\r\n]+)/i);
if (contentDisposition) {
const name = contentDisposition[1];
const filename = contentDisposition[2];
parts.push({
name,
filename,
contentType: contentType ? contentType[1].trim() : 'application/octet-stream',
data: body,
file: true
});
}
start = partEnd;
end = buffer.indexOf(boundaryBuffer, start);
}
return parts;
}
module.exports = {
parseMultipart
};
-209
View File
@@ -1,209 +0,0 @@
/**
* Peer Chat Plugin - Database Watcher
*
* Handles database change watching and broadcasting updates for real-time sync
*/
const sdk = require('../../includes/plugins/sdk');
/**
* Watch for database changes and broadcast updates
*/
async function setupDatabaseWatcher() {
// Wait for database to be available with retries
let retries = 30;
while (retries > 0) {
try {
await sdk.db.ready();
if (!sdk.db.closed) {
// Set up database watcher
sdk.db.watch(async (...args) => {
try {
const update = args[0];
// Handle different types of database updates
if (update && typeof update === 'object') {
if (update.collection === '@peerchat/messages') {
await handleMessageUpdate(update);
} else if (update.collection === '@peerchat/reactions') {
await handleReactionUpdate(update);
} else if (update.collection === '@peerchat/presence') {
await handlePresenceUpdate(update);
} else if (update.collection === '@peerchat/files') {
await handleFileUpdate(update);
} else {
// Log other collections for debugging
sdk.log.debug('peer.chat', `Database watcher detected update for collection: ${update.collection || 'unknown'}`);
}
}
} catch (err) {
sdk.log.error('peer.chat', `Error in database watcher: ${err.message}`);
}
});
sdk.log.info('peer.chat', 'Database watcher set up successfully');
return;
}
} catch (err) {
if (err.message && err.message.includes('Database not initialized')) {
retries--;
sdk.log.debug('peer.chat', `Database watcher waiting for DB, ${retries} retries left`);
await new Promise(resolve => setTimeout(resolve, 1000));
continue;
}
// Other error - log and return
sdk.log.error('peer.chat', `Error setting up database watcher: ${err.message}`);
return;
}
break;
}
if (retries === 0) {
sdk.log.warn('peer.chat', 'Database watcher setup timed out - database not available after 30 seconds');
}
}
/**
* Handle message database updates
*/
async function handleMessageUpdate(update) {
try {
// Get the updated message
const message = await sdk.db.get('@peerchat/messages', update.key);
if (!message) {
sdk.log.debug('peer.chat', `Message ${JSON.stringify(update.key)} was deleted`);
// Broadcast deletion
sdk.websocket.broadcast({
type: 'message-deleted',
messageId: update.key?.id,
peerId: update.key?.id ? null : update.key // If no id field, key might be the peerId
});
return;
}
// Determine if this is a new message or update
const isNew = !update.oldValue; // If there's no old value, it's new
sdk.log.debug('peer.chat', `${isNew ? 'New' : 'Updated'} message: ${message.id} from ${message.peerId?.slice(0, 16)}...`);
// Broadcast the message update
sdk.websocket.broadcast({
type: isNew ? 'message' : 'message-update',
message: message
});
// If this message has attachments, also broadcast file updates
if (message.attachments) {
try {
const attachments = JSON.parse(message.attachments);
if (Array.isArray(attachments) && attachments.length > 0) {
// Find files associated with this message
for (const attachment of attachments) {
const file = await sdk.db.get('@peerchat/files', { id: attachment.id });
if (file) {
sdk.websocket.broadcast({
type: 'file',
file: file
});
}
}
}
} catch (err) {
sdk.log.debug('peer.chat', `Error parsing attachments for message ${message.id}: ${err.message}`);
}
}
} catch (err) {
sdk.log.error('peer.chat', `Error handling message update: ${err.message}`);
}
}
/**
* Handle reaction database updates
*/
async function handleReactionUpdate(update) {
try {
const reaction = await sdk.db.get('@peerchat/reactions', update.key);
if (!reaction) {
sdk.log.debug('peer.chat', `Reaction removed: ${JSON.stringify(update.key)}`);
// Broadcast reaction removal
sdk.websocket.broadcast({
type: 'reaction-removed',
messageId: update.key?.messageId,
peerId: update.key?.peerId,
emoji: update.key?.emoji
});
return;
}
const isNew = !update.oldValue;
sdk.log.debug('peer.chat', `${isNew ? 'New' : 'Updated'} reaction: ${reaction.emoji} on message ${reaction.messageId} from ${reaction.peerId?.slice(0, 16)}...`);
// Broadcast the reaction
sdk.websocket.broadcast({
type: 'reaction',
reaction: reaction
});
} catch (err) {
sdk.log.error('peer.chat', `Error handling reaction update: ${err.message}`);
}
}
/**
* Handle presence database updates
*/
async function handlePresenceUpdate(update) {
try {
const presence = await sdk.db.get('@peerchat/presence', update.key);
if (!presence) {
sdk.log.debug('peer.chat', `Presence removed for peer: ${JSON.stringify(update.key)}`);
return;
}
// Only broadcast significant presence changes (not every typing update)
const oldPresence = update.oldValue;
const shouldBroadcast = !oldPresence || // New presence
oldPresence.lastSeen !== presence.lastSeen || // Last seen changed
(oldPresence.typing !== presence.typing && presence.typing); // Started typing
if (shouldBroadcast) {
sdk.log.debug('peer.chat', `Presence update for ${presence.peerId?.slice(0, 16)}...: lastSeen=${new Date(presence.lastSeen).toISOString()}, typing=${presence.typing}`);
sdk.websocket.broadcast({
type: presence.typing ? 'typing-start' : 'presence-update',
presence: presence
});
}
} catch (err) {
sdk.log.error('peer.chat', `Error handling presence update: ${err.message}`);
}
}
/**
* Handle file database updates
*/
async function handleFileUpdate(update) {
try {
const file = await sdk.db.get('@peerchat/files', update.key);
if (!file) {
sdk.log.debug('peer.chat', `File removed: ${JSON.stringify(update.key)}`);
return;
}
const isNew = !update.oldValue;
sdk.log.debug('peer.chat', `${isNew ? 'New' : 'Updated'} file: ${file.filename} (${file.id})`);
// Broadcast the file update
sdk.websocket.broadcast({
type: 'file',
file: file
});
} catch (err) {
sdk.log.error('peer.chat', `Error handling file update: ${err.message}`);
}
}
module.exports = {
setupDatabaseWatcher
};
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="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"/>
<circle cx="9" cy="10" r="1"/>
<circle cx="12" cy="10" r="1"/>
<circle cx="15" cy="10" r="1"/>
</svg>

Before

Width:  |  Height:  |  Size: 342 B

-191
View File
@@ -1,191 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>💬 P2NS Global Chatroom</title>
<meta name="description" content="Decentralized real-time chat for all P2NS peers">
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/svg+xml" href="icon.svg">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="https://global.profile/css/profile-modal.css">
</head>
<body>
<!-- Toast Notification -->
<div id="toast" class="toast"></div>
<!-- Main Container -->
<div class="chat-container">
<!-- Header -->
<header class="chat-header">
<div class="header-left">
<h1 class="header-title">
<span class="header-icon">💬</span>
<span class="header-text">P2NS Global Chatroom</span>
</h1>
</div>
<div class="header-center">
<div class="reset-timer">
<span id="resetTimer">00:00:00</span>
<span class="reset-label">until reset</span>
</div>
</div>
<div class="header-right">
<div class="status-indicator">
<div id="statusIndicator" class="status-dot disconnected"></div>
<span id="statusText">Disconnected</span>
</div>
<div class="peer-count">
<span id="peerCountValue">0</span>
<span class="peer-label">peers</span>
</div>
<button id="themeToggle" class="theme-toggle" title="Toggle theme">
<span class="theme-icon">🌙</span>
</button>
</div>
</header>
<!-- Messages Area -->
<main class="messages-container">
<!-- New Messages Indicator -->
<div id="newMessagesIndicator" class="new-messages-indicator hidden">
<button id="scrollToBottomBtn" class="scroll-to-bottom-btn">
<span id="newMessagesText">New messages</span>
<span class="scroll-icon">⬇️</span>
</button>
</div>
<div id="messagesList" class="messages-list">
<!-- Messages will be inserted here -->
<div class="loading">
<div class="loading-spinner"></div>
<div class="loading-text">Connecting to chat...</div>
</div>
</div>
<!-- Typing Indicators -->
<div id="typingIndicators" class="typing-indicators">
<!-- Typing indicators will appear here -->
</div>
</main>
<!-- Message Composer -->
<footer class="composer-container">
<!-- Reply Preview -->
<div id="replyPreview" class="reply-preview hidden">
<div class="reply-content">
<div class="reply-author">Replying to <span id="replyAuthor"></span></div>
<div class="reply-text" id="replyText"></div>
</div>
<button id="cancelReply" class="cancel-reply" title="Cancel reply" onclick="window.Editor.clearReply()"></button>
</div>
<!-- File Preview -->
<div id="filePreview" class="file-preview hidden">
<!-- File previews will appear here -->
</div>
<!-- Editor Container -->
<div class="editor-container">
<!-- Toolbar -->
<div class="editor-toolbar">
<button class="toolbar-btn" data-format="bold" title="Bold (Ctrl+B)">
<strong>B</strong>
</button>
<button class="toolbar-btn" data-format="italic" title="Italic (Ctrl+I)">
<em>I</em>
</button>
<button class="toolbar-btn" data-format="code" title="Inline code">
<code>&lt;/&gt;</code>
</button>
<button class="toolbar-btn" data-format="codeblock" title="Code block">
<span style="font-family: monospace;">{ }</span>
</button>
<button class="toolbar-btn" data-format="link" title="Link">
🔗
</button>
<button class="toolbar-btn" data-format="list" title="Bullet list">
</button>
<button class="toolbar-btn" data-format="olist" title="Numbered list">
1.
</button>
<button class="toolbar-btn" data-format="quote" title="Quote">
"
</button>
<button class="toolbar-btn" data-format="hr" title="Horizontal rule">
</button>
<button id="emojiBtn" class="toolbar-btn emoji-btn" title="Emoji">
😀
</button>
<div class="toolbar-spacer"></div>
<div class="character-count">
<span id="charCount">0</span>/<span id="maxChars">10000</span>
</div>
</div>
<!-- Text Area -->
<div class="textarea-container">
<textarea
id="messageInput"
class="message-input"
placeholder="Type your message... (Markdown supported)"
maxlength="10000"
rows="1"
autofocus
></textarea>
<!-- File Upload Button -->
<button id="fileBtn" class="file-btn" title="Attach file">
📎
</button>
<input type="file" id="fileInput" class="file-input" multiple style="display: none;">
</div>
</div>
<!-- Emoji Picker (hidden by default) -->
<div id="emojiPicker" class="emoji-picker hidden">
<!-- Emoji picker will be populated by JavaScript -->
</div>
</footer>
</div>
<!-- Context Menu -->
<div id="contextMenu" class="context-menu hidden">
<div class="context-menu-item" data-action="reply">
<span class="context-icon">↩️</span>
<span>Reply</span>
</div>
<div class="context-menu-item" data-action="react">
<span class="context-icon">😀</span>
<span>React</span>
</div>
<div class="context-menu-item" data-action="copy">
<span class="context-icon">📋</span>
<span>Copy</span>
</div>
<div class="context-menu-item own-message" data-action="edit">
<span class="context-icon">✏️</span>
<span>Edit</span>
</div>
<div class="context-menu-item own-message" data-action="delete">
<span class="context-icon">🗑️</span>
<span>Delete</span>
</div>
<div class="context-menu-item" data-action="block">
<span class="context-icon">🚫</span>
<span>Block User</span>
</div>
</div>
<!-- Scripts -->
<script src="js/websocket.js"></script>
<script src="js/app.js"></script>
<script src="js/editor.js"></script>
<script src="js/messages.js"></script>
<script src="js/reactions.js"></script>
<script src="https://global.profile/js/profile-modal.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-789
View File
@@ -1,789 +0,0 @@
/**
* Rich Text Editor for Peer Chat
* Handles markdown toolbar, emoji picker, file uploads, and keyboard shortcuts
*/
let editorMessageInput = null;
let toolbarButtons = [];
let emojiPicker = null;
let replyPreview = null;
let filePreview = null;
let currentReplyTo = null;
let attachments = []; // Array of {id, filename, mimeType, size, embedHtml, file?}
let fileObjects = new Map(); // Map of temp-id -> File object for actual upload
/**
* Initialize editor
*/
function init() {
// Get DOM elements
editorMessageInput = document.getElementById('messageInput');
replyPreview = document.getElementById('replyPreview');
filePreview = document.getElementById('filePreview');
if (!editorMessageInput) {
console.error('[Editor] Message input not found - DOM may not be ready yet');
// Try again in a moment
setTimeout(init, 100);
return;
}
// Set up toolbar buttons
setupToolbar();
// Set up emoji picker
setupEmojiPicker();
// Set up file upload
setupFileUpload();
// Set up keyboard shortcuts
setupKeyboardShortcuts();
// Set up typing indicators
setupTypingIndicators();
console.log('[Editor] Initialized');
}
/**
* Set up toolbar buttons
*/
function setupToolbar() {
toolbarButtons = document.querySelectorAll('.toolbar-btn');
toolbarButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
const format = button.dataset.format;
if (format) {
applyFormat(format);
// Refocus the input after applying format
if (editorMessageInput) {
editorMessageInput.focus();
}
}
});
});
// Emoji button
const emojiBtn = document.getElementById('emojiBtn');
if (emojiBtn) {
emojiBtn.addEventListener('click', showEmojiPicker);
}
// Character count
if (editorMessageInput) {
editorMessageInput.addEventListener('input', updateCharacterCount);
updateCharacterCount();
}
}
/**
* Set up emoji picker
*/
function setupEmojiPicker() {
emojiPicker = document.getElementById('emojiPicker');
if (!emojiPicker) {
// Create emoji picker
emojiPicker = document.createElement('div');
emojiPicker.id = 'emojiPicker';
emojiPicker.className = 'emoji-picker hidden';
const emojiGrid = document.createElement('div');
emojiGrid.className = 'emoji-grid';
getCommonEmojis().forEach(emoji => {
const emojiItem = document.createElement('div');
emojiItem.className = 'emoji-item';
emojiItem.textContent = emoji;
emojiItem.title = getEmojiName(emoji);
emojiItem.addEventListener('click', () => {
insertEmoji(emoji);
hideEmojiPicker();
});
emojiGrid.appendChild(emojiItem);
});
emojiPicker.appendChild(emojiGrid);
document.body.appendChild(emojiPicker);
}
// Hide emoji picker when clicking outside
document.addEventListener('click', (e) => {
if (!emojiPicker.contains(e.target) && e.target.id !== 'emojiBtn') {
hideEmojiPicker();
}
});
}
/**
* Set up file upload
*/
function setupFileUpload() {
const fileBtn = document.getElementById('fileBtn');
const fileInput = document.getElementById('fileInput');
if (fileBtn && fileInput) {
fileBtn.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', handleFileSelection);
}
// Drag and drop
const composer = document.querySelector('.composer-container');
if (composer) {
composer.addEventListener('dragover', handleDragOver);
composer.addEventListener('drop', handleFileDrop);
}
// Paste handler
if (editorMessageInput) {
editorMessageInput.addEventListener('paste', handlePaste);
}
}
/**
* Set up keyboard shortcuts
*/
function setupKeyboardShortcuts() {
if (!editorMessageInput) return;
editorMessageInput.addEventListener('keydown', (e) => {
// Bold: Ctrl+B
if (e.ctrlKey && e.key === 'b') {
e.preventDefault();
applyFormat('bold');
}
// Italic: Ctrl+I
if (e.ctrlKey && e.key === 'i') {
e.preventDefault();
applyFormat('italic');
}
// Code: Ctrl+`
if (e.ctrlKey && e.key === '`') {
e.preventDefault();
applyFormat('code');
}
// Link: Ctrl+K
if (e.ctrlKey && e.key === 'k') {
e.preventDefault();
applyFormat('link');
}
// Hide emoji picker on Escape
if (e.key === 'Escape') {
hideEmojiPicker();
}
});
}
/**
* Set up typing indicators
*/
function setupTypingIndicators() {
if (!editorMessageInput) return;
let typingTimeout = null;
editorMessageInput.addEventListener('input', () => {
if (typingTimeout) clearTimeout(typingTimeout);
// Send typing indicator
sendTypingIndicator(true);
// Stop typing after 2 seconds of inactivity
typingTimeout = setTimeout(() => {
sendTypingIndicator(false);
}, 2000);
});
editorMessageInput.addEventListener('blur', () => {
if (typingTimeout) {
clearTimeout(typingTimeout);
sendTypingIndicator(false);
}
});
}
/**
* Apply formatting to selected text
*/
function applyFormat(format) {
if (!editorMessageInput) return;
const start = editorMessageInput.selectionStart;
const end = editorMessageInput.selectionEnd;
const selectedText = editorMessageInput.value.substring(start, end);
const beforeText = editorMessageInput.value.substring(0, start);
const afterText = editorMessageInput.value.substring(end);
let replacement = '';
let newStart = start;
let newEnd = end;
switch (format) {
case 'bold':
if (selectedText) {
replacement = `**${selectedText}**`;
newStart = start + 2;
newEnd = end + 2;
} else {
replacement = '****';
newStart = start + 2;
newEnd = start + 2;
}
break;
case 'italic':
if (selectedText) {
replacement = `*${selectedText}*`;
newStart = start + 1;
newEnd = end + 1;
} else {
replacement = '**';
newStart = start + 1;
newEnd = start + 1;
}
break;
case 'code':
if (selectedText) {
replacement = `\`${selectedText}\``;
newStart = start + 1;
newEnd = end + 1;
} else {
replacement = '``';
newStart = start + 1;
newEnd = start + 1;
}
break;
case 'codeblock':
if (selectedText) {
replacement = `\`\`\`\n${selectedText}\n\`\`\``;
newStart = start + 4;
newEnd = start + 4 + selectedText.length;
} else {
replacement = '\`\`\`\n\n\`\`\`';
newStart = start + 4;
newEnd = start + 4;
}
break;
case 'link':
if (selectedText) {
replacement = `[${selectedText}](url)`;
newStart = end + 3;
newEnd = end + 6;
} else {
replacement = '[text](url)';
newStart = start + 1;
newEnd = start + 5;
}
break;
case 'list':
replacement = selectedText ? `- ${selectedText}` : '- ';
newStart = end + 2;
newEnd = end + 2;
break;
case 'olist':
replacement = selectedText ? `1. ${selectedText}` : '1. ';
newStart = end + 3;
newEnd = end + 3;
break;
case 'quote':
replacement = selectedText ? `> ${selectedText}` : '> ';
newStart = end + 2;
newEnd = end + 2;
break;
case 'hr':
replacement = '\n---\n';
newStart = end + 5;
newEnd = end + 5;
break;
case 'heading':
if (selectedText) {
replacement = `## ${selectedText}`;
newStart = start + 3;
newEnd = end + 3;
} else {
replacement = '## ';
newStart = start + 3;
newEnd = start + 3;
}
break;
}
editorMessageInput.value = beforeText + replacement + afterText;
editorMessageInput.focus();
editorMessageInput.setSelectionRange(newStart, newEnd);
updateCharacterCount();
}
/**
* Show emoji picker
*/
function showEmojiPicker() {
if (!emojiPicker) return;
const emojiBtn = document.getElementById('emojiBtn');
if (!emojiBtn) return;
const rect = emojiBtn.getBoundingClientRect();
emojiPicker.style.left = `${rect.left}px`;
emojiPicker.style.top = `${rect.bottom + 5}px`;
emojiPicker.classList.remove('hidden');
}
/**
* Hide emoji picker
*/
function hideEmojiPicker() {
if (emojiPicker) {
emojiPicker.classList.add('hidden');
}
}
/**
* Insert emoji at cursor position
*/
function insertEmoji(emoji) {
if (!editorMessageInput) return;
const start = editorMessageInput.selectionStart;
const end = editorMessageInput.selectionEnd;
const text = editorMessageInput.value;
const before = text.substring(0, start);
const after = text.substring(end);
editorMessageInput.value = before + emoji + after;
editorMessageInput.focus();
editorMessageInput.setSelectionRange(start + emoji.length, start + emoji.length);
updateCharacterCount();
}
/**
* Handle file selection
*/
function handleFileSelection(e) {
const files = Array.from(e.target.files);
files.forEach(file => addAttachment(file));
e.target.value = ''; // Reset input
}
/**
* Handle drag over
*/
function handleDragOver(e) {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
}
/**
* Handle file drop
*/
function handleFileDrop(e) {
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files);
files.forEach(file => addAttachment(file));
}
/**
* Handle paste event
*/
function handlePaste(e) {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.indexOf('image') !== -1) {
const file = item.getAsFile();
if (file) {
addAttachment(file);
}
}
}
}
/**
* Add attachment
*/
async function addAttachment(file) {
// Validate file size (10MB limit)
if (file.size > 10 * 1024 * 1024) {
window.App.showToast(`File "${file.name}" is too large (max 10MB)`, 'error');
return;
}
// Validate file type
const allowedTypes = ['image/', 'video/', 'audio/', 'text/', 'application/pdf'];
const isAllowed = allowedTypes.some(type => file.type.startsWith(type)) ||
['.txt', '.md', '.json', '.csv'].some(ext => file.name.toLowerCase().endsWith(ext));
if (!isAllowed) {
window.App.showToast(`File type not supported: ${file.type}`, 'error');
return;
}
try {
// Create a placeholder attachment for UI purposes
const tempId = 'temp-' + Date.now();
const placeholderAttachment = {
id: tempId,
filename: file.name,
mimeType: file.type || 'application/octet-stream',
size: file.size,
embedHtml: null, // Will be set by server when uploaded
uploading: false
};
// Store the File object for later upload
fileObjects.set(tempId, file);
// Add to attachments
attachments.push(placeholderAttachment);
updateFilePreview();
window.App.showToast(`File "${file.name}" attached`, 'success');
// Refocus the input after file attachment
if (editorMessageInput) {
editorMessageInput.focus();
}
} catch (err) {
console.error('[Editor] Error with file attachment:', err);
window.App.showToast('Failed to attach file', 'error');
// Refocus the input even on error
if (editorMessageInput) {
editorMessageInput.focus();
}
}
}
/**
* Update file preview
*/
function updateFilePreview() {
if (!filePreview) return;
if (attachments.length === 0) {
filePreview.classList.add('hidden');
filePreview.innerHTML = '';
return;
}
filePreview.classList.remove('hidden');
filePreview.innerHTML = attachments.map(attachment => {
const status = attachment.uploading ? ' (uploading...)' :
attachment.error ? ` (error: ${escapeHtml(attachment.error)})` :
attachment.id.startsWith('temp-') ? ' (pending)' : '';
return `
<div class="file-item" data-id="${attachment.id}">
<div class="file-name">${escapeHtml(attachment.filename)}${status}</div>
<button class="file-remove" onclick="window.Editor.removeAttachment('${attachment.id}'); setTimeout(() => document.getElementById('messageInput').focus(), 10);">×</button>
</div>
`;
}).join('');
}
/**
* Remove attachment
*/
function removeAttachment(attachmentId) {
attachments = attachments.filter(a => a.id !== attachmentId);
fileObjects.delete(attachmentId);
updateFilePreview();
}
/**
* Update character count
*/
function updateCharacterCount() {
if (!editorMessageInput) return;
const count = editorMessageInput.value.length;
const max = 10000;
const charCount = document.getElementById('charCount');
const maxChars = document.getElementById('maxChars');
if (charCount) {
charCount.textContent = count;
}
if (maxChars) {
maxChars.textContent = max;
}
// Visual feedback for approaching limit
editorMessageInput.style.color = count > max * 0.9 ? 'var(--error)' : '';
}
/**
* Send typing indicator
*/
function sendTypingIndicator(isTyping) {
// Send typing indicator via WebSocket
window.WebSocketClient.send({
type: 'typing',
isTyping: isTyping
});
}
/**
* Set reply to message
*/
function setReplyTo(message) {
currentReplyTo = message;
if (replyPreview) {
const profile = window.App.getProfile(message.peerId);
document.getElementById('replyAuthor').textContent = profile.displayName;
document.getElementById('replyText').textContent = message.text.slice(0, 100) + (message.text.length > 100 ? '...' : '');
replyPreview.classList.remove('hidden');
}
if (editorMessageInput) {
editorMessageInput.focus();
}
}
/**
* Clear reply
*/
function clearReply() {
currentReplyTo = null;
if (replyPreview) {
replyPreview.classList.add('hidden');
}
// Refocus the input when cancelling reply
if (editorMessageInput) {
editorMessageInput.focus();
}
}
/**
* Clear editor
*/
function clear() {
if (editorMessageInput) {
editorMessageInput.value = '';
}
attachments = [];
fileObjects.clear();
clearReply();
updateFilePreview();
updateCharacterCount();
}
/**
* Get reply to message
*/
function getReplyTo() {
return currentReplyTo;
}
/**
* Upload all pending files and replace temp IDs with real IDs
*/
async function uploadFiles() {
const uploadPromises = [];
for (const attachment of attachments) {
// Skip if already uploaded (not a temp ID) or already uploading
if (!attachment.id.startsWith('temp-') || attachment.uploading) {
continue;
}
const file = fileObjects.get(attachment.id);
if (!file) {
console.warn('[Editor] File object not found for:', attachment.id);
continue;
}
// Mark as uploading
attachment.uploading = true;
updateFilePreview();
// Create upload promise
const uploadPromise = (async () => {
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/attachments/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!result.success || !result.id) {
throw new Error('Invalid upload response');
}
// Save temp ID before replacing
const tempId = attachment.id;
// Replace temp ID with real ID
attachment.id = result.id;
attachment.embedHtml = result.embedHtml;
attachment.uploading = false;
// Remove from fileObjects map using temp ID
fileObjects.delete(tempId);
console.log('[Editor] File uploaded:', result.id, result.filename);
return attachment;
} catch (err) {
console.error('[Editor] Error uploading file:', err);
attachment.uploading = false;
attachment.error = err.message;
updateFilePreview();
throw err;
}
})();
uploadPromises.push(uploadPromise);
}
// Wait for all uploads to complete
if (uploadPromises.length > 0) {
await Promise.all(uploadPromises);
updateFilePreview();
}
return attachments.filter(a => !a.id.startsWith('temp-') && !a.error);
}
/**
* Get attachments
*/
function getAttachments() {
return attachments;
}
/**
* Edit message (placeholder)
*/
function editMessage(message) {
// In a real implementation, this would populate the editor with the message content
window.App.showToast('Edit message (not implemented yet)', 'info');
}
/**
* Get common emojis
*/
function getCommonEmojis() {
return [
'😀', '😂', '😊', '😍', '🥰', '😘', '😉', '😎',
'🤔', '😮', '😢', '😡', '🥺', '😴', '🤤', '😵',
'👍', '👎', '👌', '✌️', '🤞', '👏', '🙌', '🤝',
'❤️', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎',
'🔥', '⭐', '✨', '💫', '🎉', '🎊', '🎈', '🎁'
];
}
/**
* Get emoji name
*/
function getEmojiName(emoji) {
const names = {
'😀': 'Grinning Face',
'😂': 'Face with Tears of Joy',
'😊': 'Smiling Face',
'😍': 'Smiling Face with Heart-Eyes',
'🥰': 'Smiling Face with Hearts',
'😘': 'Face Blowing a Kiss',
'😉': 'Winking Face',
'😎': 'Smiling Face with Sunglasses',
'🤔': 'Thinking Face',
'😮': 'Face with Open Mouth',
'😢': 'Crying Face',
'😡': 'Pouting Face',
'🥺': 'Pleading Face',
'😴': 'Sleeping Face',
'🤤': 'Drooling Face',
'😵': 'Dizzy Face',
'👍': 'Thumbs Up',
'👎': 'Thumbs Down',
'👌': 'OK Hand',
'✌️': 'Victory Hand',
'🤞': 'Crossed Fingers',
'👏': 'Clapping Hands',
'🙌': 'Raising Hands',
'🤝': 'Handshake',
'❤️': 'Red Heart',
'💛': 'Yellow Heart',
'💚': 'Green Heart',
'💙': 'Blue Heart',
'💜': 'Purple Heart',
'🖤': 'Black Heart',
'🤍': 'White Heart',
'🤎': 'Brown Heart',
'🔥': 'Fire',
'⭐': 'Star',
'✨': 'Sparkles',
'💫': 'Dizzy',
'🎉': 'Party Popper',
'🎊': 'Confetti Ball',
'🎈': 'Balloon',
'🎁': 'Wrapped Gift'
};
return names[emoji] || emoji;
}
/**
* Escape HTML
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Export functions
window.Editor = {
init,
applyFormat,
insertEmoji,
setReplyTo,
clear,
getReplyTo,
getAttachments,
uploadFiles,
removeAttachment,
editMessage
};
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
// Small delay to ensure all elements are ready
setTimeout(init, 10);
});
} else {
// Small delay to ensure all elements are ready
setTimeout(init, 10);
}
-561
View File
@@ -1,561 +0,0 @@
/**
* Message Rendering and Management for Peer Chat
* Handles markdown rendering, reactions, context menu, and message interactions
*/
// Message elements cache
const messageElements = new Map();
let contextMenu = null;
let contextMenuTarget = null;
/**
* Initialize messages module
*/
function init() {
// Get context menu element
contextMenu = document.getElementById('contextMenu');
// Set up context menu event listeners
if (contextMenu) {
setupContextMenu();
}
// Set up global click handler to hide context menu
document.addEventListener('click', hideContextMenu);
document.addEventListener('contextmenu', handleGlobalContextMenu);
console.log('[Messages] Initialized');
}
/**
* Render all messages
*/
function renderMessages(messages, prepend = false) {
const container = document.getElementById('messagesList');
if (!container) return;
if (!prepend) {
// Clear existing messages
container.innerHTML = '';
messageElements.clear();
}
// Remove loading indicator if present
const loadingElement = container.querySelector('.loading');
if (loadingElement) {
console.log('[Messages] Removing loading indicator');
loadingElement.remove();
} else {
console.log('[Messages] No loading indicator found to remove');
}
messages.forEach(message => {
const messageElement = createMessageElement(message);
if (prepend) {
container.insertBefore(messageElement, container.firstChild);
} else {
container.appendChild(messageElement);
}
messageElements.set(message.id, messageElement);
});
}
/**
* Render a single message
*/
function renderMessage(message) {
const container = document.getElementById('messagesList');
if (!container) return;
const messageElement = createMessageElement(message);
// Insert message in correct position based on createdAt to maintain chronological order
// Since messages array is sorted, find the index in the array and insert DOM element accordingly
const messages = window.App.getMessages();
const messageIndex = messages.findIndex(m => m.id === message.id);
if (messageIndex < 0) {
// Message not in array yet, just append (shouldn't happen, but fallback)
container.appendChild(messageElement);
} else {
// Find the DOM element at the corresponding position
const children = Array.from(container.children);
let insertBefore = null;
// Find the first DOM element that corresponds to a message after this one in the array
for (let i = messageIndex + 1; i < messages.length; i++) {
const nextMessageId = messages[i].id;
const existingElement = messageElements.get(nextMessageId);
if (existingElement && existingElement.parentNode === container) {
insertBefore = existingElement;
break;
}
}
// Insert at the correct position
if (insertBefore) {
container.insertBefore(messageElement, insertBefore);
} else {
container.appendChild(messageElement);
}
}
messageElements.set(message.id, messageElement);
}
/**
* Update a message
*/
function updateMessage(message) {
const existingElement = messageElements.get(message.id);
if (existingElement) {
const newElement = createMessageElement(message);
existingElement.replaceWith(newElement);
messageElements.set(message.id, newElement);
}
}
/**
* Update message reactions
*/
function updateMessageReactions(message) {
const existingElement = messageElements.get(message.id);
if (existingElement) {
const reactionsContainer = existingElement.querySelector('.message-reactions');
if (reactionsContainer) {
reactionsContainer.innerHTML = createReactionsHTML(message.reactions || []);
}
}
}
/**
* Remove a message
*/
function removeMessage(messageId) {
const element = messageElements.get(messageId);
if (element) {
element.remove();
messageElements.delete(messageId);
}
}
/**
* Create message element
*/
function createMessageElement(message) {
const div = document.createElement('div');
div.className = `message ${message.peerId === window.App.getCurrentUser()?.peerId ? 'own' : ''}`;
div.dataset.messageId = message.id;
// Get profile - prioritize avatar from message if available
const cachedProfile = window.App.getProfile(message.peerId);
const profile = {
...cachedProfile,
displayName: message.displayName || cachedProfile.displayName,
avatar: message.avatar !== undefined ? message.avatar : cachedProfile.avatar
};
// Create message HTML
div.innerHTML = `
<img src="${getAvatarUrl(profile)}" alt="${profile.displayName}" class="message-avatar" onerror="this.style.display='none'">
<div class="message-content">
<div class="message-header">
<span class="message-author" data-peer-id="${message.peerId}">${escapeHtml(profile.displayName)}</span>
<span class="message-timestamp">${formatTimestamp(message.createdAt)}</span>
</div>
${message.replyTo ? createReplyHTML(message.replyTo) : ''}
<div class="message-body">${renderMarkdown(message.text)}</div>
${message.attachments ? createAttachmentsHTML(message.attachments) : ''}
<div class="message-actions">
<button class="action-btn reply-btn" title="Reply">💬</button>
<button class="action-btn react-btn" title="React">😀</button>
</div>
<div class="message-reactions">${createReactionsHTML(message.reactions || [])}</div>
${message.editedAt ? '<div class="message-edited">(edited)</div>' : ''}
${message.deletedAt ? '<div class="message-deleted">This message was deleted</div>' : ''}
</div>
`;
// Set up event listeners
setupMessageEventListeners(div, message);
return div;
}
/**
* Set up event listeners for message element
*/
function setupMessageEventListeners(element, message) {
// Author click
const authorElement = element.querySelector('.message-author');
if (authorElement) {
authorElement.addEventListener('click', () => {
showProfileModal(message.peerId);
});
}
// Reply button
const replyBtn = element.querySelector('.reply-btn');
if (replyBtn) {
replyBtn.addEventListener('click', () => {
if (window.Editor) {
window.Editor.setReplyTo(message);
}
});
}
// React button
const reactBtn = element.querySelector('.react-btn');
if (reactBtn) {
reactBtn.addEventListener('click', () => {
showReactionPicker(message);
});
}
// Context menu
element.addEventListener('contextmenu', (e) => {
e.preventDefault();
showContextMenu(e, message);
});
}
/**
* Create reply HTML
*/
function createReplyHTML(replyToId) {
// Find the replied message
const repliedMessage = window.App.getMessages().find(m => m.id === replyToId);
if (!repliedMessage) return '';
const profile = window.App.getProfile(repliedMessage.peerId);
return `
<div class="message-reply">
<div class="reply-author">Replying to ${escapeHtml(profile.displayName)}</div>
<div class="reply-text">${escapeHtml(repliedMessage.text.slice(0, 100))}${repliedMessage.text.length > 100 ? '...' : ''}</div>
</div>
`;
}
/**
* Create attachments HTML
*/
function createAttachmentsHTML(attachments) {
try {
const attachmentList = JSON.parse(attachments);
if (!Array.isArray(attachmentList) || attachmentList.length === 0) return '';
return `
<div class="message-attachments">
${attachmentList.map(attachment => {
// If file has embed HTML (images, audio, video), use it
if (attachment.embedHtml) {
return `<div class="attachment">${attachment.embedHtml}</div>`;
} else {
// Otherwise show as downloadable link
return `
<div class="attachment">
<a href="/api/files/${attachment.id}" target="_blank" class="attachment-link">
📎 ${escapeHtml(attachment.filename)}
</a>
</div>
`;
}
}).join('')}
</div>
`;
} catch (err) {
return '';
}
}
/**
* Create reactions HTML
*/
function createReactionsHTML(reactions) {
if (!reactions || reactions.length === 0) return '';
// Group reactions by emoji
const reactionCounts = {};
const userReactions = new Set();
reactions.forEach(reaction => {
if (!reactionCounts[reaction.emoji]) {
reactionCounts[reaction.emoji] = {
count: 0,
users: []
};
}
reactionCounts[reaction.emoji].count++;
reactionCounts[reaction.emoji].users.push(reaction.peerId);
// Check if current user reacted
if (reaction.peerId === window.App.getCurrentUser()?.peerId) {
userReactions.add(reaction.emoji);
}
});
return Object.entries(reactionCounts)
.map(([emoji, data]) => `
<button class="reaction ${userReactions.has(emoji) ? 'active' : ''}"
data-emoji="${emoji}"
title="${data.users.map(id => window.App.getProfile(id).displayName).join(', ')}">
<span class="reaction-emoji">${emoji}</span>
<span class="reaction-count">${data.count}</span>
</button>
`)
.join('');
}
/**
* Render markdown content
*/
function renderMarkdown(text) {
if (!text) return '';
try {
// Simple markdown-like rendering (could be enhanced with marked.js)
let html = escapeHtml(text);
// Code blocks
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
// Inline code
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold
html = html.replace(/\*\*([^\*]+)\*\*/g, '<strong>$1</strong>');
// Italic
html = html.replace(/\*([^\*]+)\*/g, '<em>$1</em>');
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Line breaks
html = html.replace(/\n/g, '<br>');
return html;
} catch (err) {
console.error('[Messages] Error rendering markdown:', err);
return escapeHtml(text);
}
}
/**
* Get avatar URL for profile
*/
function getAvatarUrl(profile) {
if (profile.avatar) {
// In P2NS, this is from global.profile API using cross-domain URL (avatar contains avatarHash)
return `https://global.profile/api/profile/avatar/${encodeURIComponent(profile.peerId)}/64`;
}
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHZpZXdCb3g9IjAgMCA2NCA2NCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMzIiIGN5PSIzMiIgcj0iMzIiIGZpbGw9IiM2MzY2RjEiLz4KPHBhdGggZD0iTTQwIDI0QzQwIDI5LjUyMjggMzUuNTIyOCAzNCAzMCAzNFMyMCAyOS41MjI4IDIwIDI0UzI0LjQ3NzIgMjAgMzAgMjBTNDAgMjQuNDc3MiA0MCAyNFoiIGZpbGw9IiNmZmYiLz4KPHBhdGggZD0iTTMwIDM0QzI1LjAyOTQgMzQgMjEgMzguOTcwNiAyMSAzMEMyMSAzNS4wMjk0IDI1LjAyOTQgMzEgMzAgMzFDMzQuOTcwNiAzMSAzOSA0MS4wMjk0IDM5IDMwQzM5IDM4Ljk3MDYgMzQgMzBMMzQiIGZpbGw9IiNmZmYiLz4KPHN2Zz4=';
}
/**
* Format timestamp
*/
function formatTimestamp(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
if (diff < 60000) { // Less than 1 minute
return 'now';
} else if (diff < 3600000) { // Less than 1 hour
return `${Math.floor(diff / 60000)}m ago`;
} else if (diff < 86400000) { // Less than 1 day
return `${Math.floor(diff / 3600000)}h ago`;
} else {
return date.toLocaleDateString();
}
}
/**
* Escape HTML
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Show profile modal using Global.Profile SDK
*/
function showProfileModal(peerId) {
if (window.ProfileModal && typeof window.ProfileModal.open === 'function') {
window.ProfileModal.open(peerId);
} else {
// Fallback if ProfileModal is not available
const profile = window.App.getProfile(peerId);
window.App.showToast(`Profile: ${profile.displayName}`, 'info');
}
}
/**
* Show reaction picker
*/
function showReactionPicker(message) {
// Simple reaction picker - in a real implementation, this would show an emoji picker
const reactions = ['👍', '❤️', '😂', '😮', '😢', '😡'];
// For demo, just add a random reaction
const emoji = reactions[Math.floor(Math.random() * reactions.length)];
addReaction(message.id, emoji);
}
/**
* Add reaction to message
*/
function addReaction(messageId, emoji) {
window.WebSocketClient.send({
type: 'add-reaction',
messageId: messageId,
emoji: emoji
});
}
/**
* Set up context menu
*/
function setupContextMenu() {
// Set up context menu item clicks
contextMenu.addEventListener('click', (e) => {
const action = e.target.closest('.context-menu-item')?.dataset.action;
if (!action || !contextMenuTarget) return;
switch (action) {
case 'reply':
if (window.Editor) {
window.Editor.setReplyTo(contextMenuTarget);
}
break;
case 'react':
showReactionPicker(contextMenuTarget);
break;
case 'copy':
copyMessageToClipboard(contextMenuTarget);
break;
case 'edit':
if (window.Editor) {
window.Editor.editMessage(contextMenuTarget);
}
break;
case 'delete':
deleteMessage(contextMenuTarget);
break;
case 'block':
blockPeer(contextMenuTarget.peerId);
break;
}
hideContextMenu();
});
}
/**
* Show context menu
*/
function showContextMenu(event, message) {
if (!contextMenu) return;
contextMenuTarget = message;
// Position context menu
contextMenu.style.left = `${event.pageX}px`;
contextMenu.style.top = `${event.pageY}px`;
// Update menu items based on message ownership
const ownMessage = message.peerId === window.App.getCurrentUser()?.peerId;
contextMenu.querySelectorAll('.own-message').forEach(item => {
item.style.display = ownMessage ? 'flex' : 'none';
});
// Show menu
contextMenu.classList.remove('hidden');
// Prevent event bubbling
event.stopPropagation();
}
/**
* Hide context menu
*/
function hideContextMenu() {
if (contextMenu) {
contextMenu.classList.add('hidden');
}
contextMenuTarget = null;
}
/**
* Handle global context menu to hide when clicking elsewhere
*/
function handleGlobalContextMenu(e) {
// Allow context menu on messages
if (e.target.closest('.message')) {
return;
}
// Hide context menu for other elements
hideContextMenu();
}
/**
* Copy message to clipboard
*/
function copyMessageToClipboard(message) {
navigator.clipboard.writeText(message.text).then(() => {
window.App.showToast('Message copied to clipboard', 'success');
}).catch(() => {
window.App.showToast('Failed to copy message', 'error');
});
}
/**
* Delete message
*/
async function deleteMessage(message) {
if (!confirm('Are you sure you want to delete this message?')) return;
try {
// Send delete message via WebSocket
if (!window.WebSocketClient || !window.WebSocketClient.isConnected()) {
throw new Error('WebSocket not connected');
}
window.WebSocketClient.send({
type: 'delete-message',
data: {
messageId: message.id
}
});
// Note: The UI will be updated when we receive the 'message-deleted' event
// from the WebSocket, so we don't need to update it here
console.log('[Messages] Delete message request sent:', message.id);
} catch (err) {
console.error('[Messages] Error deleting message:', err);
window.App.showToast('Failed to delete message', 'error');
}
}
/**
* Block peer (placeholder)
*/
function blockPeer(peerId) {
window.App.showToast(`Blocking peer ${peerId} (not implemented yet)`, 'info');
// In a real implementation, this would call the P2NS SDK to block the peer
}
// Export functions
window.Messages = {
init,
renderMessages,
renderMessage,
updateMessage,
updateMessageReactions,
removeMessage
};
-204
View File
@@ -1,204 +0,0 @@
/**
* Reaction System for Peer Chat
* Handles emoji picker and reaction interactions
*/
let reactionPicker = null;
let currentReactionMessage = null;
/**
* Initialize reactions module
*/
function init() {
// Create reaction picker element
createReactionPicker();
// Set up global click handler to hide picker
document.addEventListener('click', hideReactionPicker);
console.log('[Reactions] Initialized');
}
/**
* Create reaction picker element
*/
function createReactionPicker() {
reactionPicker = document.createElement('div');
reactionPicker.className = 'reaction-picker hidden';
reactionPicker.innerHTML = `
<div class="reaction-grid">
${getCommonEmojis().map(emoji => `
<button class="reaction-item" data-emoji="${emoji}" title="${getEmojiName(emoji)}">
${emoji}
</button>
`).join('')}
</div>
`;
document.body.appendChild(reactionPicker);
// Set up click handlers
reactionPicker.addEventListener('click', (e) => {
const emoji = e.target.closest('.reaction-item')?.dataset.emoji;
if (emoji && currentReactionMessage) {
addReaction(currentReactionMessage.id, emoji);
hideReactionPicker();
}
});
}
/**
* Show reaction picker for a message
*/
function showReactionPicker(message) {
if (!reactionPicker || !message) return;
currentReactionMessage = message;
// Position picker near the message
const messageElement = document.querySelector(`[data-message-id="${message.id}"]`);
if (messageElement) {
const rect = messageElement.getBoundingClientRect();
reactionPicker.style.left = `${rect.right + 10}px`;
reactionPicker.style.top = `${rect.top}px`;
reactionPicker.classList.remove('hidden');
}
}
/**
* Hide reaction picker
*/
function hideReactionPicker() {
if (reactionPicker) {
reactionPicker.classList.add('hidden');
}
currentReactionMessage = null;
}
/**
* Add reaction to message
*/
async function addReaction(messageId, emoji) {
try {
const response = await fetch(`/api/messages/${messageId}/reactions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ emoji })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
console.log(`[Reactions] Added reaction ${emoji} to message ${messageId}`);
} catch (err) {
console.error('[Reactions] Error adding reaction:', err);
window.App.showToast('Failed to add reaction', 'error');
}
}
/**
* Remove reaction from message
*/
async function removeReaction(messageId, emoji) {
try {
const response = await fetch(`/api/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
console.log(`[Reactions] Removed reaction ${emoji} from message ${messageId}`);
} catch (err) {
console.error('[Reactions] Error removing reaction:', err);
window.App.showToast('Failed to remove reaction', 'error');
}
}
/**
* Get common emojis for picker
*/
function getCommonEmojis() {
return [
'👍', '👎', '❤️', '😂', '😮', '😢', '😡', '🤔',
'🎉', '🔥', '💯', '✅', '❌', '🤝', '🙏', '👀',
'🚀', '💡', '🎯', '⚡', '🌟', '💎', '🎨', '🎵'
];
}
/**
* Get emoji name for tooltip
*/
function getEmojiName(emoji) {
const names = {
'👍': 'Thumbs Up',
'👎': 'Thumbs Down',
'❤️': 'Heart',
'😂': 'Laughing',
'😮': 'Surprised',
'😢': 'Crying',
'😡': 'Angry',
'🤔': 'Thinking',
'🎉': 'Party',
'🔥': 'Fire',
'💯': 'Perfect',
'✅': 'Check',
'❌': 'Cross',
'🤝': 'Handshake',
'🙏': 'Pray',
'👀': 'Eyes',
'🚀': 'Rocket',
'💡': 'Lightbulb',
'🎯': 'Target',
'⚡': 'Lightning',
'🌟': 'Star',
'💎': 'Gem',
'🎨': 'Art',
'🎵': 'Music'
};
return names[emoji] || emoji;
}
/**
* Handle reaction click (toggle reaction)
*/
function handleReactionClick(messageId, emoji, event) {
event.stopPropagation();
// Check if user already reacted with this emoji
const message = window.App.getMessages().find(m => m.id === messageId);
if (!message || !message.reactions) return;
const userReaction = message.reactions.find(r =>
r.peerId === window.App.getCurrentUser()?.peerId && r.emoji === emoji
);
if (userReaction) {
// Remove reaction
removeReaction(messageId, emoji);
} else {
// Add reaction
addReaction(messageId, emoji);
}
}
// Export functions
window.Reactions = {
init,
showReactionPicker,
addReaction,
removeReaction,
handleReactionClick
};
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
-197
View File
@@ -1,197 +0,0 @@
/**
* WebSocket Client for Peer Chat
* Handles real-time communication with the server
*/
// WebSocket state
let ws = null;
let reconnectTimeout = null;
let reconnectDelay = 1000;
const MAX_RECONNECT_DELAY = 30000;
let isConnected = false;
// Message handlers registry
const messageHandlers = new Map();
// Connection status callback
let onConnectionChange = null;
/**
* Initialize WebSocket connection
*/
function initWebSocket(onConnectionCallback) {
onConnectionChange = onConnectionCallback;
connect();
}
/**
* Connect to WebSocket server
*/
function connect() {
// Don't connect if already connected or connecting
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
try {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[WebSocket] Connected');
reconnectDelay = 1000; // Reset delay on successful connection
isConnected = true;
// Send identification message to get peer ID and initial data (delay to avoid conflicts)
setTimeout(() => {
const identifyMessage = {
type: 'identify',
clientInfo: {
userAgent: navigator.userAgent,
timestamp: Date.now()
}
};
console.log('[WebSocket] Sending identify message:', JSON.stringify(identifyMessage));
sendWebSocketMessage(identifyMessage);
}, 100);
if (onConnectionChange) {
onConnectionChange(true);
}
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('[WebSocket] Received message:', data.type);
handleMessage(data);
} catch (err) {
console.error('[WebSocket] Error parsing message:', err);
}
};
ws.onclose = () => {
console.log('[WebSocket] Disconnected');
isConnected = false;
if (onConnectionChange) {
onConnectionChange(false);
}
ws = null;
// Attempt to reconnect with exponential backoff
reconnectTimeout = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
console.log(`[WebSocket] Attempting reconnect in ${reconnectDelay}ms`);
connect();
}, reconnectDelay);
};
ws.onerror = (err) => {
console.error('[WebSocket] Error:', err);
};
} catch (err) {
console.error('[WebSocket] Error creating connection:', err);
}
}
/**
* Handle incoming WebSocket messages
*/
function handleMessage(data) {
console.log('[WebSocket] Received:', data.type, data);
// Call registered handlers for this message type
const handlers = messageHandlers.get(data.type);
if (handlers) {
handlers.forEach(handler => {
try {
handler(data);
} catch (err) {
console.error(`[WebSocket] Error in ${data.type} handler:`, err);
}
});
}
}
/**
* Register a message handler
*/
function onMessage(type, handler) {
if (!messageHandlers.has(type)) {
messageHandlers.set(type, []);
}
messageHandlers.get(type).push(handler);
}
/**
* Remove a message handler
*/
function offMessage(type, handler) {
const handlers = messageHandlers.get(type);
if (handlers) {
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
if (handlers.length === 0) {
messageHandlers.delete(type);
}
}
}
}
/**
* Send a message via WebSocket
*/
function sendWebSocketMessage(data) {
console.log('[WebSocket] Sending message:', data.type);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
console.log('[WebSocket] Message sent successfully');
return true;
}
console.warn('[WebSocket] Cannot send message - not connected');
return false;
}
/**
* Get connection status
*/
function getConnectionStatus() {
return {
connected: isConnected,
readyState: ws ? ws.readyState : WebSocket.CLOSED
};
}
/**
* Close WebSocket connection
*/
function close() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
if (ws) {
ws.close();
ws = null;
}
isConnected = false;
}
// Export functions
window.WebSocketClient = {
init: initWebSocket,
connect,
send: sendWebSocketMessage,
on: onMessage,
off: offMessage,
close,
getStatus: getConnectionStatus,
isConnected: () => isConnected
};
-38
View File
@@ -1,38 +0,0 @@
{
"name": "Peer Chat - P2NS Global Chatroom",
"short_name": "Peer Chat",
"description": "Decentralized real-time chat for all P2NS peers",
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#2563eb",
"orientation": "portrait-primary",
"categories": ["communication", "social"],
"lang": "en",
"dir": "ltr",
"icons": [
{
"src": "icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "New Message",
"short_name": "Compose",
"description": "Start a new chat message",
"url": "/#compose",
"icons": [
{
"src": "icon.svg",
"sizes": "any"
}
]
}
],
"related_applications": [],
"prefer_related_applications": false,
"screenshots": []
}