diff --git a/README.md b/README.md index 9ebe0f1..0058414 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,10 @@ A lightweight web interface for meshcore-cli, providing browser-based access to ### Key Features -- 馃摫 **View messages** - Display chat history from Public channel with auto-refresh -- 鉁夛笍 **Send messages** - Publish to Public channel (200 char limit for LoRa) +- 馃摫 **View messages** - Display chat history with auto-refresh +- 鉁夛笍 **Send messages** - Publish to any channel (200 char limit for LoRa) +- 馃摗 **Channel management** - Create, join, and switch between encrypted channels +- 馃攼 **Channel sharing** - Share channels via QR code or encrypted keys - 馃挰 **Reply to users** - Quick reply with `@[UserName]` format - 馃Ч **Clean contacts** - Remove inactive contacts with configurable threshold - 馃摝 **Message archiving** - Automatic daily archiving with browse-by-date selector @@ -142,15 +144,39 @@ See [PRD.md](PRD.md) for detailed requirements and implementation plan. ### Viewing Messages -The main page displays chat history from the Public channel (channel 0). Messages auto-refresh every 60 seconds by default. +The main page displays chat history from the currently selected channel. Messages auto-refresh every 60 seconds by default. By default, the live view shows messages from the last 7 days. Older messages are automatically archived and can be accessed via the date selector. +### Managing Channels + +Access channel management by clicking the broadcast icon (馃摗) in the navbar: + +#### Creating a New Channel +1. Click "Add New Channel" +2. Enter a channel name (letters, numbers, _ and - only) +3. Click "Create & Auto-generate Key" +4. The channel is created with a secure encryption key + +#### Sharing a Channel +1. In the Channels modal, click the share icon next to any channel +2. Share the QR code (scan with another device) or copy the encryption key +3. Others can join using the "Join Existing" option + +#### Joining a Channel +1. Click "Join Existing" +2. Enter the channel name and encryption key (received from channel creator) +3. Click "Join Channel" +4. The channel will be added to your available channels + +#### Switching Channels +Use the channel selector dropdown in the navbar to switch between channels. Your selection is remembered between sessions. + ### Viewing Message Archives Access historical messages using the date selector in the navbar: -1. Click the date dropdown in the navbar (next to Refresh button) +1. Click the date dropdown in the navbar 2. Select a date to view archived messages for that day 3. Select "Today (Live)" to return to live view @@ -158,9 +184,10 @@ Archives are created automatically at midnight (00:00 UTC) each day. The live vi ### Sending Messages -1. Type your message in the text field at the bottom -2. Press Enter or click "Send" -3. Your message will be published to the Public channel +1. Select your target channel using the channel selector +2. Type your message in the text field at the bottom +3. Press Enter or click "Send" +4. Your message will be published to the selected channel ### Replying to Users diff --git a/app/meshcore/cli.py b/app/meshcore/cli.py index 45da771..1ab054e 100644 --- a/app/meshcore/cli.py +++ b/app/meshcore/cli.py @@ -4,7 +4,8 @@ MeshCore CLI wrapper - executes meshcli commands via subprocess import subprocess import logging -from typing import Tuple, Optional +import re +from typing import Tuple, Optional, List, Dict from app.config import config logger = logging.getLogger(__name__) @@ -75,13 +76,14 @@ def recv_messages() -> Tuple[bool, str]: return success, stdout or stderr -def send_message(text: str, reply_to: Optional[str] = None) -> Tuple[bool, str]: +def send_message(text: str, reply_to: Optional[str] = None, channel_index: int = 0) -> Tuple[bool, str]: """ - Send a message to the Public channel. + Send a message to a specific channel. Args: text: Message content reply_to: Optional username to reply to (will format as @[username]) + channel_index: Channel to send to (default: 0 = Public) Returns: Tuple of (success, message) @@ -91,7 +93,13 @@ def send_message(text: str, reply_to: Optional[str] = None) -> Tuple[bool, str]: else: message = text - success, stdout, stderr = _run_command(['public', message]) + if channel_index == 0: + # Public channel - backward compatibility + success, stdout, stderr = _run_command(['public', message]) + else: + # Other channels - use 'chan' command + success, stdout, stderr = _run_command(['chan', str(channel_index), message]) + return success, stdout or stderr @@ -144,3 +152,107 @@ def check_connection() -> bool: """ success, _, _ = _run_command(['infos'], timeout=5) return success + + +def get_channels() -> Tuple[bool, List[Dict]]: + """ + Get list of configured channels. + + Returns: + Tuple of (success, list of channel dicts) + Each dict: { + 'index': int, + 'name': str, + 'key': str + } + """ + success, stdout, stderr = _run_command(['get_channels']) + + if not success: + return False, [] + + channels = [] + for line in stdout.split('\n'): + line = line.strip() + if not line: + continue + + # Parse: "0: Public [8b3387e9c5cdea6ac9e5edbaa115cd72]" + match = re.match(r'^(\d+):\s+(.+?)\s+\[([a-f0-9]{32})\]$', line) + if match: + channels.append({ + 'index': int(match.group(1)), + 'name': match.group(2), + 'key': match.group(3) + }) + + return True, channels + + +def add_channel(name: str) -> Tuple[bool, str, Optional[str]]: + """ + Add a new channel with auto-generated key. + + Args: + name: Channel name + + Returns: + Tuple of (success, message, key_or_none) + key_or_none: The generated key if successful, None otherwise + """ + success, stdout, stderr = _run_command(['add_channel', name]) + + if not success: + return False, stderr or stdout, None + + # Get channels to find the newly created one + success_ch, channels = get_channels() + if success_ch: + for ch in channels: + if ch['name'] == name: + return True, f"Channel '{name}' created", ch['key'] + + return True, stdout or stderr, None + + +def set_channel(index: int, name: str, key: str) -> Tuple[bool, str]: + """ + Set/join a channel at specific index with name and key. + + Args: + index: Channel slot number + name: Channel name + key: 32-char hex key + + Returns: + Tuple of (success, message) + """ + # Validate key format + if not re.match(r'^[a-f0-9]{32}$', key.lower()): + return False, "Invalid key format (must be 32 hex characters)" + + success, stdout, stderr = _run_command([ + 'set_channel', + str(index), + name, + key.lower() + ]) + + return success, stdout or stderr + + +def remove_channel(index: int) -> Tuple[bool, str]: + """ + Remove a channel. + + Args: + index: Channel number to remove + + Returns: + Tuple of (success, message) + """ + if index == 0: + return False, "Cannot remove Public channel (channel 0)" + + success, stdout, stderr = _run_command(['remove_channel', str(index)]) + return success, stdout or stderr diff --git a/app/meshcore/parser.py b/app/meshcore/parser.py index 197653a..2388a8e 100644 --- a/app/meshcore/parser.py +++ b/app/meshcore/parser.py @@ -12,12 +12,13 @@ from app.config import config logger = logging.getLogger(__name__) -def parse_message(line: Dict) -> Optional[Dict]: +def parse_message(line: Dict, allowed_channels: Optional[List[int]] = None) -> Optional[Dict]: """ Parse a single message line from .msgs file. Args: line: Raw JSON object from .msgs file + allowed_channels: List of channel indices to include (None = all channels) Returns: Parsed message dict or None if not a valid chat message @@ -25,8 +26,8 @@ def parse_message(line: Dict) -> Optional[Dict]: msg_type = line.get('type') channel_idx = line.get('channel_idx', 0) - # Only process Public channel (channel 0) messages - if channel_idx != 0: + # Filter by allowed channels + if allowed_channels is not None and channel_idx not in allowed_channels: return None # Only process CHAN (received) and SENT_CHAN (sent) messages @@ -65,11 +66,12 @@ def parse_message(line: Dict) -> Optional[Dict]: 'datetime': datetime.fromtimestamp(timestamp).isoformat() if timestamp > 0 else None, 'is_own': is_own, 'snr': line.get('SNR'), - 'path_len': line.get('path_len') + 'path_len': line.get('path_len'), + 'channel_idx': channel_idx } -def read_messages(limit: Optional[int] = None, offset: int = 0, archive_date: Optional[str] = None, days: Optional[int] = None) -> List[Dict]: +def read_messages(limit: Optional[int] = None, offset: int = 0, archive_date: Optional[str] = None, days: Optional[int] = None, channel_idx: Optional[int] = None) -> List[Dict]: """ Read and parse messages from .msgs file or archive file. @@ -78,13 +80,14 @@ def read_messages(limit: Optional[int] = None, offset: int = 0, archive_date: Op offset: Number of messages to skip from the end archive_date: If provided, read from archive file for this date (YYYY-MM-DD) days: If provided, filter messages from the last N days (only for live .msgs) + channel_idx: Filter messages by channel (None = all channels) Returns: List of parsed message dictionaries, sorted by timestamp (oldest first) """ # If archive_date is provided, read from archive if archive_date: - return read_archive_messages(archive_date, limit, offset) + return read_archive_messages(archive_date, limit, offset, channel_idx) msgs_file = config.msgs_file_path @@ -92,6 +95,9 @@ def read_messages(limit: Optional[int] = None, offset: int = 0, archive_date: Op logger.warning(f"Messages file not found: {msgs_file}") return [] + # Determine allowed channels + allowed_channels = [channel_idx] if channel_idx is not None else None + messages = [] try: @@ -103,7 +109,7 @@ def read_messages(limit: Optional[int] = None, offset: int = 0, archive_date: Op try: data = json.loads(line) - parsed = parse_message(data) + parsed = parse_message(data, allowed_channels=allowed_channels) if parsed: messages.append(parsed) except json.JSONDecodeError as e: @@ -159,7 +165,7 @@ def count_messages() -> int: return len(read_messages()) -def read_archive_messages(archive_date: str, limit: Optional[int] = None, offset: int = 0) -> List[Dict]: +def read_archive_messages(archive_date: str, limit: Optional[int] = None, offset: int = 0, channel_idx: Optional[int] = None) -> List[Dict]: """ Read messages from an archive file. @@ -167,6 +173,7 @@ def read_archive_messages(archive_date: str, limit: Optional[int] = None, offset archive_date: Archive date in YYYY-MM-DD format limit: Maximum number of messages to return (None = all) offset: Number of messages to skip from the end + channel_idx: Filter messages by channel (None = all channels) Returns: List of parsed message dictionaries, sorted by timestamp (oldest first) @@ -179,6 +186,9 @@ def read_archive_messages(archive_date: str, limit: Optional[int] = None, offset logger.warning(f"Archive file not found: {archive_file}") return [] + # Determine allowed channels + allowed_channels = [channel_idx] if channel_idx is not None else None + messages = [] try: @@ -190,7 +200,7 @@ def read_archive_messages(archive_date: str, limit: Optional[int] = None, offset try: data = json.loads(line) - parsed = parse_message(data) + parsed = parse_message(data, allowed_channels=allowed_channels) if parsed: messages.append(parsed) except json.JSONDecodeError as e: diff --git a/app/routes/api.py b/app/routes/api.py index 92110f9..8006afc 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -3,8 +3,12 @@ REST API endpoints for mc-webui """ import logging +import json +import re +import base64 from datetime import datetime -from flask import Blueprint, jsonify, request +from io import BytesIO +from flask import Blueprint, jsonify, request, send_file from app.meshcore import cli, parser from app.config import config from app.archiver import manager as archive_manager @@ -17,13 +21,14 @@ api_bp = Blueprint('api', __name__, url_prefix='/api') @api_bp.route('/messages', methods=['GET']) def get_messages(): """ - Get list of messages from Public channel or archive. + Get list of messages from specific channel or archive. Query parameters: limit (int): Maximum number of messages to return offset (int): Number of messages to skip from the end archive_date (str): View archive for specific date (YYYY-MM-DD format) days (int): Show only messages from last N days (live view only) + channel_idx (int): Filter by channel index (optional) Returns: JSON with messages list @@ -33,6 +38,7 @@ def get_messages(): offset = request.args.get('offset', default=0, type=int) archive_date = request.args.get('archive_date', type=str) days = request.args.get('days', type=int) + channel_idx = request.args.get('channel_idx', type=int) # Validate archive_date format if provided if archive_date: @@ -49,14 +55,16 @@ def get_messages(): limit=limit, offset=offset, archive_date=archive_date, - days=days + days=days, + channel_idx=channel_idx ) return jsonify({ 'success': True, 'count': len(messages), 'messages': messages, - 'archive_date': archive_date if archive_date else None + 'archive_date': archive_date if archive_date else None, + 'channel_idx': channel_idx }), 200 except Exception as e: @@ -70,11 +78,12 @@ def get_messages(): @api_bp.route('/messages', methods=['POST']) def send_message(): """ - Send a message to the Public channel. + Send a message to a specific channel. JSON body: text (str): Message content (required) reply_to (str): Username to reply to (optional) + channel_idx (int): Channel to send to (optional, default: 0) Returns: JSON with success status @@ -105,14 +114,16 @@ def send_message(): }), 400 reply_to = data.get('reply_to') + channel_idx = data.get('channel_idx', 0) # Send message via meshcli - success, message = cli.send_message(text, reply_to=reply_to) + success, message = cli.send_message(text, reply_to=reply_to, channel_index=channel_idx) if success: return jsonify({ 'success': True, - 'message': 'Message sent successfully' + 'message': 'Message sent successfully', + 'channel_idx': channel_idx }), 200 else: return jsonify({ @@ -336,3 +347,299 @@ def trigger_archive(): 'success': False, 'error': str(e) }), 500 + + +@api_bp.route('/channels', methods=['GET']) +def get_channels(): + """ + Get list of configured channels. + + Returns: + JSON with channels list + """ + try: + success, channels = cli.get_channels() + + if success: + return jsonify({ + 'success': True, + 'channels': channels, + 'count': len(channels) + }), 200 + else: + return jsonify({ + 'success': False, + 'error': 'Failed to retrieve channels' + }), 500 + + except Exception as e: + logger.error(f"Error getting channels: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@api_bp.route('/channels', methods=['POST']) +def create_channel(): + """ + Create a new channel with auto-generated key. + + JSON body: + name (str): Channel name (required) + + Returns: + JSON with created channel info + """ + try: + data = request.get_json() + + if not data or 'name' not in data: + return jsonify({ + 'success': False, + 'error': 'Missing required field: name' + }), 400 + + name = data['name'].strip() + if not name: + return jsonify({ + 'success': False, + 'error': 'Channel name cannot be empty' + }), 400 + + # Validate name (no special chars that could break CLI) + if not re.match(r'^[a-zA-Z0-9_\-]+$', name): + return jsonify({ + 'success': False, + 'error': 'Channel name can only contain letters, numbers, _ and -' + }), 400 + + success, message, key = cli.add_channel(name) + + if success: + return jsonify({ + 'success': True, + 'message': message, + 'channel': { + 'name': name, + 'key': key + } + }), 201 + else: + return jsonify({ + 'success': False, + 'error': message + }), 500 + + except Exception as e: + logger.error(f"Error creating channel: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@api_bp.route('/channels/join', methods=['POST']) +def join_channel(): + """ + Join an existing channel by setting name and key. + + JSON body: + name (str): Channel name (required) + key (str): 32-char hex key (required) + index (int): Channel slot (optional, auto-detect if not provided) + + Returns: + JSON with result + """ + try: + data = request.get_json() + + if not data or 'name' not in data or 'key' not in data: + return jsonify({ + 'success': False, + 'error': 'Missing required fields: name, key' + }), 400 + + name = data['name'].strip() + key = data['key'].strip().lower() + + # Auto-detect free slot if not provided + if 'index' in data: + index = int(data['index']) + else: + # Find first free slot (1-7, skip 0 which is Public) + success_ch, channels = cli.get_channels() + if not success_ch: + return jsonify({ + 'success': False, + 'error': 'Failed to get current channels' + }), 500 + + used_indices = {ch['index'] for ch in channels} + index = None + for i in range(1, 8): # Assume max 8 channels + if i not in used_indices: + index = i + break + + if index is None: + return jsonify({ + 'success': False, + 'error': 'No free channel slots available' + }), 400 + + success, message = cli.set_channel(index, name, key) + + if success: + return jsonify({ + 'success': True, + 'message': f'Joined channel "{name}" at slot {index}', + 'channel': { + 'index': index, + 'name': name, + 'key': key + } + }), 200 + else: + return jsonify({ + 'success': False, + 'error': message + }), 500 + + except Exception as e: + logger.error(f"Error joining channel: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@api_bp.route('/channels/', methods=['DELETE']) +def delete_channel(index): + """ + Remove a channel. + + Args: + index: Channel index to remove + + Returns: + JSON with result + """ + try: + success, message = cli.remove_channel(index) + + if success: + return jsonify({ + 'success': True, + 'message': f'Channel {index} removed' + }), 200 + else: + return jsonify({ + 'success': False, + 'error': message + }), 500 + + except Exception as e: + logger.error(f"Error removing channel: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@api_bp.route('/channels//qr', methods=['GET']) +def get_channel_qr(index): + """ + Generate QR code for channel sharing. + + Args: + index: Channel index + + Query params: + format: 'json' (default) or 'png' + + Returns: + JSON with QR data or PNG image + """ + try: + import qrcode + + # Get channel info + success, channels = cli.get_channels() + if not success: + return jsonify({ + 'success': False, + 'error': 'Failed to get channels' + }), 500 + + channel = next((ch for ch in channels if ch['index'] == index), None) + if not channel: + return jsonify({ + 'success': False, + 'error': f'Channel {index} not found' + }), 404 + + # Create QR data + qr_data = { + 'type': 'meshcore_channel', + 'name': channel['name'], + 'key': channel['key'] + } + qr_json = json.dumps(qr_data) + + format_type = request.args.get('format', 'json') + + if format_type == 'png': + # Generate PNG QR code + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=10, + border=4, + ) + qr.add_data(qr_json) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + + # Convert to PNG bytes + buf = BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + + return send_file(buf, mimetype='image/png') + + else: # JSON format + # Generate base64 data URL for inline display + qr = qrcode.QRCode(version=1, box_size=10, border=4) + qr.add_data(qr_json) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + buf = BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + + img_base64 = base64.b64encode(buf.read()).decode() + data_url = f"data:image/png;base64,{img_base64}" + + return jsonify({ + 'success': True, + 'qr_data': qr_data, + 'qr_image': data_url, + 'qr_text': qr_json + }), 200 + + except ImportError: + return jsonify({ + 'success': False, + 'error': 'QR code library not available' + }), 500 + + except Exception as e: + logger.error(f"Error generating QR code: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 diff --git a/app/static/js/app.js b/app/static/js/app.js index e3f1930..6a79397 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -7,11 +7,22 @@ let lastMessageCount = 0; let autoRefreshInterval = null; let isUserScrolling = false; let currentArchiveDate = null; // Current selected archive date (null = live) +let currentChannelIdx = 0; // Current active channel (0 = Public) +let availableChannels = []; // List of channels from API // Initialize on page load document.addEventListener('DOMContentLoaded', function() { console.log('mc-webui initialized'); + // Load channels list + loadChannels(); + + // Restore last selected channel from localStorage + const savedChannel = localStorage.getItem('mc_active_channel'); + if (savedChannel !== null) { + currentChannelIdx = parseInt(savedChannel); + } + // Load archive list loadArchiveList(); @@ -85,6 +96,95 @@ function setupEventListeners() { settingsModal.addEventListener('show.bs.modal', function() { loadDeviceInfo(); }); + + // Channel selector + document.getElementById('channelSelector').addEventListener('change', function(e) { + currentChannelIdx = parseInt(e.target.value); + localStorage.setItem('mc_active_channel', currentChannelIdx); + loadMessages(); + + const channelName = e.target.options[e.target.selectedIndex].text; + showNotification(`Switched to channel: ${channelName}`, 'info'); + }); + + // Channels modal - load channels when opened + const channelsModal = document.getElementById('channelsModal'); + channelsModal.addEventListener('show.bs.modal', function() { + loadChannelsList(); + }); + + // Create channel form + document.getElementById('createChannelForm').addEventListener('submit', async function(e) { + e.preventDefault(); + + const name = document.getElementById('newChannelName').value.trim(); + + try { + const response = await fetch('/api/channels', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name: name }) + }); + + const data = await response.json(); + + if (data.success) { + showNotification(`Channel "${name}" created!`, 'success'); + document.getElementById('newChannelName').value = ''; + document.getElementById('addChannelForm').classList.remove('show'); + + // Reload channels + await loadChannels(); + loadChannelsList(); + } else { + showNotification('Failed to create channel: ' + data.error, 'danger'); + } + } catch (error) { + showNotification('Failed to create channel', 'danger'); + } + }); + + // Join channel form + document.getElementById('joinChannelFormSubmit').addEventListener('submit', async function(e) { + e.preventDefault(); + + const name = document.getElementById('joinChannelName').value.trim(); + const key = document.getElementById('joinChannelKey').value.trim().toLowerCase(); + + try { + const response = await fetch('/api/channels/join', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name: name, key: key }) + }); + + const data = await response.json(); + + if (data.success) { + showNotification(`Joined channel "${name}"!`, 'success'); + document.getElementById('joinChannelName').value = ''; + document.getElementById('joinChannelKey').value = ''; + document.getElementById('joinChannelForm').classList.remove('show'); + + // Reload channels + await loadChannels(); + loadChannelsList(); + } else { + showNotification('Failed to join channel: ' + data.error, 'danger'); + } + } catch (error) { + showNotification('Failed to join channel', 'danger'); + } + }); + + // Scan QR button (placeholder) + document.getElementById('scanQRBtn').addEventListener('click', function() { + showNotification('QR scanning feature coming soon! For now, manually enter the channel details.', 'info'); + }); } /** @@ -95,6 +195,9 @@ async function loadMessages() { // Build URL with appropriate parameters let url = '/api/messages?limit=500'; + // Add channel filter + url += `&channel_idx=${currentChannelIdx}`; + if (currentArchiveDate) { // Loading archive url += `&archive_date=${currentArchiveDate}`; @@ -205,7 +308,10 @@ async function sendMessage() { headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text: text }) + body: JSON.stringify({ + text: text, + channel_idx: currentChannelIdx + }) }); const data = await response.json(); @@ -531,3 +637,190 @@ function setupEmojiPicker() { } }); } + +/** + * Load list of available channels + */ +async function loadChannels() { + try { + const response = await fetch('/api/channels'); + const data = await response.json(); + + if (data.success) { + availableChannels = data.channels; + populateChannelSelector(data.channels); + } else { + console.error('Error loading channels:', data.error); + } + } catch (error) { + console.error('Error loading channels:', error); + } +} + +/** + * Populate channel selector dropdown + */ +function populateChannelSelector(channels) { + const selector = document.getElementById('channelSelector'); + + // Clear current options + selector.innerHTML = ''; + + // Add channels + channels.forEach(channel => { + const option = document.createElement('option'); + option.value = channel.index; + option.textContent = channel.name; + selector.appendChild(option); + }); + + // Set current selection + selector.value = currentChannelIdx; + + console.log(`Loaded ${channels.length} channels`); +} + +/** + * Load channels list in management modal + */ +async function loadChannelsList() { + const listEl = document.getElementById('channelsList'); + listEl.innerHTML = '
Loading...
'; + + try { + const response = await fetch('/api/channels'); + const data = await response.json(); + + if (data.success) { + displayChannelsList(data.channels); + } else { + listEl.innerHTML = '
Error loading channels
'; + } + } catch (error) { + listEl.innerHTML = '
Failed to load channels
'; + } +} + +/** + * Display channels in management modal + */ +function displayChannelsList(channels) { + const listEl = document.getElementById('channelsList'); + + if (channels.length === 0) { + listEl.innerHTML = '
No channels configured
'; + return; + } + + listEl.innerHTML = ''; + + channels.forEach(channel => { + const item = document.createElement('div'); + item.className = 'list-group-item d-flex justify-content-between align-items-center'; + + const isPublic = channel.index === 0; + + item.innerHTML = ` +
+ ${escapeHtml(channel.name)} +
+ ${channel.key} +
+
+ + ${!isPublic ? ` + + ` : ''} +
+ `; + + listEl.appendChild(item); + }); +} + +/** + * Delete channel + */ +async function deleteChannel(index) { + const channel = availableChannels.find(ch => ch.index === index); + if (!channel) return; + + if (!confirm(`Remove channel "${channel.name}"?`)) { + return; + } + + try { + const response = await fetch(`/api/channels/${index}`, { + method: 'DELETE' + }); + + const data = await response.json(); + + if (data.success) { + showNotification(`Channel "${channel.name}" removed`, 'success'); + + // If deleted current channel, switch to Public + if (currentChannelIdx === index) { + currentChannelIdx = 0; + localStorage.setItem('mc_active_channel', '0'); + loadMessages(); + } + + // Reload channels + await loadChannels(); + loadChannelsList(); + } else { + showNotification('Failed to remove channel: ' + data.error, 'danger'); + } + } catch (error) { + showNotification('Failed to remove channel', 'danger'); + } +} + +/** + * Share channel (show QR code) + */ +async function shareChannel(index) { + try { + const response = await fetch(`/api/channels/${index}/qr`); + const data = await response.json(); + + if (data.success) { + // Populate share modal + document.getElementById('shareChannelName').textContent = `Channel: ${data.qr_data.name}`; + document.getElementById('shareChannelQR').src = data.qr_image; + document.getElementById('shareChannelKey').value = data.qr_data.key; + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('shareChannelModal')); + modal.show(); + } else { + showNotification('Failed to generate QR code: ' + data.error, 'danger'); + } + } catch (error) { + showNotification('Failed to generate QR code', 'danger'); + } +} + +/** + * Copy channel key to clipboard + */ +function copyChannelKey() { + const input = document.getElementById('shareChannelKey'); + input.select(); + document.execCommand('copy'); + showNotification('Channel key copied to clipboard!', 'success'); +} + +/** + * Escape HTML to prevent XSS + */ +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/app/templates/base.html b/app/templates/base.html index c7e9f54..5db61bd 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -36,6 +36,13 @@ + + + Only letters, numbers, _ and - + + + + + + + +
+
+
Join Existing Channel
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + + + + + +