@@ -1,3 +0,0 @@
|
||||
db
|
||||
spec
|
||||
drives
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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 |
@@ -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></></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
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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": []
|
||||
}
|
||||
Reference in New Issue
Block a user