mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-05-18 07:15:49 +02:00
feat: Add direct messages (DM) support
- Parse PRIV (incoming) and SENT_MSG (outgoing) message types - Add DM API endpoints: conversations, messages, updates - Implement conversation grouping by pubkey_prefix or name - Add timeout-based delivery status (pending → timeout) - Add DM modal with conversation list and thread views - Add dual notification badge (blue=channels, green=DM) - Add DM button next to Reply on channel messages - Include message deduplication for both incoming and outgoing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ A lightweight web interface for meshcore-cli, providing browser-based access to
|
||||
- 🔔 **Smart notifications** - Bell icon with unread message counter across all channels
|
||||
- 📊 **Per-channel badges** - Unread count displayed on each channel in selector
|
||||
- ✉️ **Send messages** - Publish to any channel (140 byte limit for LoRa)
|
||||
- 💌 **Direct messages (DM)** - Send and receive private messages with delivery status tracking
|
||||
- 📡 **Channel management** - Create, join, and switch between encrypted channels
|
||||
- 🔐 **Channel sharing** - Share channels via QR code or encrypted keys
|
||||
- 🔓 **Public channels** - Join public channels (starting with #) without encryption keys
|
||||
@@ -166,10 +167,10 @@ mc-webui/
|
||||
- [x] Public Channels (# prefix support, auto-key generation)
|
||||
- [x] Message Archiving (Daily archiving with browse-by-date selector)
|
||||
- [x] Smart Notifications (Unread counters per channel and total)
|
||||
- [x] Direct Messages (DM) - Private messaging with delivery status tracking
|
||||
|
||||
### Next Steps
|
||||
|
||||
- [ ] **Private Messages (DM)** - Send and receive direct messages with delivery status tracking
|
||||
- [ ] Performance Optimization - Frontend and backend improvements
|
||||
- [ ] Enhanced Testing - Unit and integration tests
|
||||
- [ ] Documentation Polish - API docs and usage guides
|
||||
@@ -251,6 +252,32 @@ Archives are created automatically at midnight (00:00 UTC) each day. The live vi
|
||||
|
||||
Click the reply button on any message to insert `@[UserName]` into the text field, then type your reply.
|
||||
|
||||
### Direct Messages (DM)
|
||||
|
||||
Access the Direct Messages feature from the slide-out menu:
|
||||
|
||||
1. Click the menu icon (☰) in the navbar
|
||||
2. Select "Direct Messages" from the menu
|
||||
3. View your conversation list with unread indicators
|
||||
|
||||
**Starting a new conversation:**
|
||||
- Click the "DM" button next to any channel message to start a private chat with that user
|
||||
- Or select an existing conversation from the DM list
|
||||
|
||||
**Sending a direct message:**
|
||||
1. Open a conversation
|
||||
2. Type your message (max 200 characters)
|
||||
3. Press Enter or click Send
|
||||
|
||||
**Message status indicators:**
|
||||
- ⏳ **Pending** (yellow) - Message sent, waiting for delivery confirmation
|
||||
- ⏱️ **Timeout** (red) - Delivery confirmation not received within expected time
|
||||
|
||||
**Notifications:**
|
||||
- The bell icon shows a secondary green badge for unread DMs
|
||||
- Each conversation shows unread count in the conversation list
|
||||
- DM badge in the menu shows total unread DM count
|
||||
|
||||
### Managing Contacts
|
||||
|
||||
Access the settings panel to clean up inactive contacts:
|
||||
|
||||
@@ -300,3 +300,30 @@ def floodadv() -> Tuple[bool, str]:
|
||||
"""
|
||||
success, stdout, stderr = _run_command(['floodadv'])
|
||||
return success, stdout or stderr
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Direct Messages (DM)
|
||||
# =============================================================================
|
||||
|
||||
def send_dm(recipient: str, text: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Send a direct/private message to a contact.
|
||||
|
||||
Uses meshcli 'msg' command: msg <name> <message>
|
||||
|
||||
Args:
|
||||
recipient: Contact name to send to
|
||||
text: Message content
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
if not recipient or not recipient.strip():
|
||||
return False, "Recipient name is required"
|
||||
|
||||
if not text or not text.strip():
|
||||
return False, "Message text is required"
|
||||
|
||||
success, stdout, stderr = _run_command(['msg', recipient.strip(), text.strip()])
|
||||
return success, stdout or stderr
|
||||
|
||||
+318
-1
@@ -1,11 +1,13 @@
|
||||
"""
|
||||
Message parser - reads and parses .msgs file (JSON Lines format)
|
||||
Supports channel messages (CHAN, SENT_CHAN) and direct messages (PRIV, SENT_MSG)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
from app.config import config
|
||||
|
||||
@@ -306,3 +308,318 @@ def delete_channel_messages(channel_idx: int) -> bool:
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting channel messages: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Direct Messages (DM) Parsing
|
||||
# =============================================================================
|
||||
|
||||
def _parse_priv_message(line: Dict) -> Optional[Dict]:
|
||||
"""
|
||||
Parse incoming private message (PRIV type).
|
||||
|
||||
Args:
|
||||
line: Raw JSON object from .msgs file with type='PRIV'
|
||||
|
||||
Returns:
|
||||
Parsed DM dict or None if invalid
|
||||
"""
|
||||
text = line.get('text', '').strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
timestamp = line.get('timestamp', 0)
|
||||
pubkey_prefix = line.get('pubkey_prefix', '')
|
||||
sender = line.get('name', 'Unknown')
|
||||
sender_timestamp = line.get('sender_timestamp', 0)
|
||||
|
||||
# Generate conversation ID - prefer pubkey_prefix if available
|
||||
if pubkey_prefix:
|
||||
conversation_id = f"pk_{pubkey_prefix}"
|
||||
else:
|
||||
conversation_id = f"name_{sender}"
|
||||
|
||||
# Generate deduplication key
|
||||
text_hash = hash(text[:50]) & 0xFFFFFFFF # 32-bit positive hash
|
||||
dedup_key = f"priv_{pubkey_prefix}_{sender_timestamp}_{text_hash}"
|
||||
|
||||
return {
|
||||
'type': 'dm',
|
||||
'direction': 'incoming',
|
||||
'sender': sender,
|
||||
'content': text,
|
||||
'timestamp': timestamp,
|
||||
'sender_timestamp': sender_timestamp,
|
||||
'datetime': datetime.fromtimestamp(timestamp).isoformat() if timestamp > 0 else None,
|
||||
'is_own': False,
|
||||
'snr': line.get('SNR'),
|
||||
'path_len': line.get('path_len'),
|
||||
'pubkey_prefix': pubkey_prefix,
|
||||
'txt_type': line.get('txt_type', 0),
|
||||
'conversation_id': conversation_id,
|
||||
'dedup_key': dedup_key
|
||||
}
|
||||
|
||||
|
||||
def _parse_sent_msg(line: Dict) -> Optional[Dict]:
|
||||
"""
|
||||
Parse outgoing private message (SENT_MSG type).
|
||||
|
||||
Args:
|
||||
line: Raw JSON object from .msgs file with type='SENT_MSG'
|
||||
|
||||
Returns:
|
||||
Parsed DM dict or None if invalid
|
||||
"""
|
||||
text = line.get('text', '').strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
timestamp = line.get('timestamp', 0)
|
||||
recipient = line.get('name', 'Unknown')
|
||||
expected_ack = line.get('expected_ack', '')
|
||||
suggested_timeout = line.get('suggested_timeout', 10000) # Default 10s
|
||||
|
||||
# Generate conversation ID from recipient name
|
||||
conversation_id = f"name_{recipient}"
|
||||
|
||||
# Deduplication key - use expected_ack if available
|
||||
if expected_ack:
|
||||
dedup_key = f"sent_{expected_ack}"
|
||||
else:
|
||||
text_hash = hash(text[:50]) & 0xFFFFFFFF
|
||||
dedup_key = f"sent_{timestamp}_{text_hash}"
|
||||
|
||||
# Calculate status based on timeout
|
||||
age_ms = (time.time() - timestamp) * 1000
|
||||
if age_ms > suggested_timeout:
|
||||
status = 'timeout'
|
||||
else:
|
||||
status = 'pending'
|
||||
|
||||
return {
|
||||
'type': 'dm',
|
||||
'direction': 'outgoing',
|
||||
'recipient': recipient,
|
||||
'content': text,
|
||||
'timestamp': timestamp,
|
||||
'datetime': datetime.fromtimestamp(timestamp).isoformat() if timestamp > 0 else None,
|
||||
'is_own': True,
|
||||
'expected_ack': expected_ack,
|
||||
'suggested_timeout': suggested_timeout,
|
||||
'status': status,
|
||||
'txt_type': line.get('txt_type', 0),
|
||||
'conversation_id': conversation_id,
|
||||
'dedup_key': dedup_key
|
||||
}
|
||||
|
||||
|
||||
def parse_dm_message(line: Dict) -> Optional[Dict]:
|
||||
"""
|
||||
Parse a DM message (PRIV or SENT_MSG) from .msgs file.
|
||||
|
||||
Args:
|
||||
line: Raw JSON object from .msgs file
|
||||
|
||||
Returns:
|
||||
Parsed DM dict or None if not a valid DM message
|
||||
"""
|
||||
msg_type = line.get('type')
|
||||
|
||||
if msg_type == 'PRIV':
|
||||
return _parse_priv_message(line)
|
||||
elif msg_type == 'SENT_MSG':
|
||||
return _parse_sent_msg(line)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def read_dm_messages(
|
||||
limit: Optional[int] = None,
|
||||
conversation_id: Optional[str] = None,
|
||||
days: Optional[int] = 7
|
||||
) -> Tuple[List[Dict], Dict[str, str]]:
|
||||
"""
|
||||
Read and parse DM messages from .msgs file.
|
||||
|
||||
Args:
|
||||
limit: Maximum messages to return (None = all)
|
||||
conversation_id: Filter by specific conversation (None = all)
|
||||
days: Filter to last N days (None = no filter)
|
||||
|
||||
Returns:
|
||||
Tuple of (messages_list, pubkey_to_name_mapping)
|
||||
The mapping helps correlate outgoing messages (name only) with incoming (pubkey)
|
||||
"""
|
||||
msgs_file = config.msgs_file_path
|
||||
|
||||
if not msgs_file.exists():
|
||||
logger.warning(f"Messages file not found: {msgs_file}")
|
||||
return [], {}
|
||||
|
||||
messages = []
|
||||
seen_dedup_keys = set()
|
||||
pubkey_to_name = {} # Map pubkey_prefix -> most recent name
|
||||
|
||||
try:
|
||||
with open(msgs_file, 'r', encoding='utf-8') as f:
|
||||
for line_num, line in enumerate(f, 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(line)
|
||||
parsed = parse_dm_message(data)
|
||||
|
||||
if not parsed:
|
||||
continue
|
||||
|
||||
# Update pubkey->name mapping from incoming messages
|
||||
if parsed['direction'] == 'incoming' and parsed.get('pubkey_prefix'):
|
||||
pubkey_to_name[parsed['pubkey_prefix']] = parsed['sender']
|
||||
|
||||
# Deduplicate
|
||||
if parsed['dedup_key'] in seen_dedup_keys:
|
||||
continue
|
||||
seen_dedup_keys.add(parsed['dedup_key'])
|
||||
|
||||
# Filter by conversation if specified
|
||||
if conversation_id:
|
||||
if parsed['conversation_id'] != conversation_id:
|
||||
# Also check if it matches via pubkey->name mapping
|
||||
# For outgoing messages, conversation_id is name-based
|
||||
# but incoming might be pk-based
|
||||
if conversation_id.startswith('pk_'):
|
||||
pk = conversation_id[3:]
|
||||
name = pubkey_to_name.get(pk)
|
||||
if name and parsed['conversation_id'] == f"name_{name}":
|
||||
# Match via name
|
||||
pass
|
||||
else:
|
||||
continue
|
||||
elif conversation_id.startswith('name_'):
|
||||
name = conversation_id[5:]
|
||||
# Check if any pubkey maps to this name
|
||||
matching_pk = None
|
||||
for pk, n in pubkey_to_name.items():
|
||||
if n == name:
|
||||
matching_pk = pk
|
||||
break
|
||||
if matching_pk and parsed['conversation_id'] == f"pk_{matching_pk}":
|
||||
# Match via pubkey
|
||||
pass
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
messages.append(parsed)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Invalid JSON at line {line_num}: {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing DM at line {line_num}: {e}")
|
||||
continue
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Messages file not found: {msgs_file}")
|
||||
return [], {}
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading messages file: {e}")
|
||||
return [], {}
|
||||
|
||||
# Sort by timestamp (oldest first)
|
||||
messages.sort(key=lambda m: m['timestamp'])
|
||||
|
||||
# Filter by days if specified
|
||||
if days is not None and days > 0:
|
||||
cutoff_timestamp = (datetime.now() - timedelta(days=days)).timestamp()
|
||||
messages = [m for m in messages if m['timestamp'] >= cutoff_timestamp]
|
||||
|
||||
# Apply limit (return most recent)
|
||||
if limit is not None and limit > 0:
|
||||
messages = messages[-limit:]
|
||||
|
||||
logger.info(f"Loaded {len(messages)} DM messages")
|
||||
return messages, pubkey_to_name
|
||||
|
||||
|
||||
def get_dm_conversations(days: Optional[int] = 7) -> List[Dict]:
|
||||
"""
|
||||
Get list of DM conversations with metadata.
|
||||
|
||||
Args:
|
||||
days: Filter to last N days (None = no filter)
|
||||
|
||||
Returns:
|
||||
List of conversation dicts sorted by most recent activity:
|
||||
[
|
||||
{
|
||||
'conversation_id': str,
|
||||
'display_name': str,
|
||||
'pubkey_prefix': str or None,
|
||||
'last_message_timestamp': int,
|
||||
'last_message_preview': str,
|
||||
'unread_count': int, # Always 0 here, frontend tracks unread
|
||||
'message_count': int
|
||||
}
|
||||
]
|
||||
"""
|
||||
messages, pubkey_to_name = read_dm_messages(days=days)
|
||||
|
||||
# Group messages by conversation
|
||||
conversations = {}
|
||||
|
||||
for msg in messages:
|
||||
conv_id = msg['conversation_id']
|
||||
|
||||
# For incoming messages with pubkey, also try to merge with name-based
|
||||
if conv_id.startswith('pk_'):
|
||||
pk = conv_id[3:]
|
||||
name = pubkey_to_name.get(pk)
|
||||
# Check if there's a name-based conversation we should merge
|
||||
name_conv_id = f"name_{name}" if name else None
|
||||
if name_conv_id and name_conv_id in conversations:
|
||||
# Merge into pubkey-based conversation
|
||||
conversations[conv_id] = conversations.pop(name_conv_id)
|
||||
|
||||
if conv_id not in conversations:
|
||||
conversations[conv_id] = {
|
||||
'conversation_id': conv_id,
|
||||
'display_name': '',
|
||||
'pubkey_prefix': None,
|
||||
'last_message_timestamp': 0,
|
||||
'last_message_preview': '',
|
||||
'unread_count': 0,
|
||||
'message_count': 0
|
||||
}
|
||||
|
||||
conv = conversations[conv_id]
|
||||
conv['message_count'] += 1
|
||||
|
||||
# Update display name
|
||||
if msg['direction'] == 'incoming':
|
||||
conv['display_name'] = msg['sender']
|
||||
if msg.get('pubkey_prefix'):
|
||||
conv['pubkey_prefix'] = msg['pubkey_prefix']
|
||||
elif msg['direction'] == 'outgoing' and not conv['display_name']:
|
||||
conv['display_name'] = msg.get('recipient', 'Unknown')
|
||||
|
||||
# Update last message info
|
||||
if msg['timestamp'] > conv['last_message_timestamp']:
|
||||
conv['last_message_timestamp'] = msg['timestamp']
|
||||
preview = msg['content'][:50]
|
||||
if len(msg['content']) > 50:
|
||||
preview += '...'
|
||||
if msg['is_own']:
|
||||
preview = f"You: {preview}"
|
||||
conv['last_message_preview'] = preview
|
||||
|
||||
# Convert to list and sort by most recent
|
||||
result = list(conversations.values())
|
||||
result.sort(key=lambda c: c['last_message_timestamp'], reverse=True)
|
||||
|
||||
logger.info(f"Found {len(result)} DM conversations")
|
||||
return result
|
||||
|
||||
@@ -895,3 +895,273 @@ def get_messages_updates():
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Direct Messages (DM) Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@api_bp.route('/dm/conversations', methods=['GET'])
|
||||
def get_dm_conversations():
|
||||
"""
|
||||
Get list of DM conversations.
|
||||
|
||||
Query params:
|
||||
days (int): Filter to last N days (default: 7)
|
||||
|
||||
Returns:
|
||||
JSON with conversations list:
|
||||
{
|
||||
"success": true,
|
||||
"conversations": [
|
||||
{
|
||||
"conversation_id": "pk_4563b1621b58",
|
||||
"display_name": "daniel5120",
|
||||
"pubkey_prefix": "4563b1621b58",
|
||||
"last_message_timestamp": 1766491173,
|
||||
"last_message_preview": "Hello there...",
|
||||
"unread_count": 0,
|
||||
"message_count": 15
|
||||
}
|
||||
],
|
||||
"count": 5
|
||||
}
|
||||
"""
|
||||
try:
|
||||
days = request.args.get('days', default=7, type=int)
|
||||
|
||||
conversations = parser.get_dm_conversations(days=days)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'conversations': conversations,
|
||||
'count': len(conversations)
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting DM conversations: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.route('/dm/messages', methods=['GET'])
|
||||
def get_dm_messages():
|
||||
"""
|
||||
Get DM messages for a specific conversation.
|
||||
|
||||
Query params:
|
||||
conversation_id (str): Required - conversation identifier (pk_xxx or name_xxx)
|
||||
limit (int): Max messages to return (default: 100)
|
||||
days (int): Filter to last N days (default: 7)
|
||||
|
||||
Returns:
|
||||
JSON with messages list:
|
||||
{
|
||||
"success": true,
|
||||
"conversation_id": "pk_4563b1621b58",
|
||||
"display_name": "daniel5120",
|
||||
"messages": [...],
|
||||
"count": 25
|
||||
}
|
||||
"""
|
||||
try:
|
||||
conversation_id = request.args.get('conversation_id', type=str)
|
||||
if not conversation_id:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Missing required parameter: conversation_id'
|
||||
}), 400
|
||||
|
||||
limit = request.args.get('limit', default=100, type=int)
|
||||
days = request.args.get('days', default=7, type=int)
|
||||
|
||||
messages, pubkey_to_name = parser.read_dm_messages(
|
||||
limit=limit,
|
||||
conversation_id=conversation_id,
|
||||
days=days
|
||||
)
|
||||
|
||||
# Determine display name from conversation_id or messages
|
||||
display_name = 'Unknown'
|
||||
if conversation_id.startswith('pk_'):
|
||||
pk = conversation_id[3:]
|
||||
display_name = pubkey_to_name.get(pk, pk[:8] + '...')
|
||||
elif conversation_id.startswith('name_'):
|
||||
display_name = conversation_id[5:]
|
||||
|
||||
# Also check messages for better name
|
||||
for msg in messages:
|
||||
if msg['direction'] == 'incoming' and msg.get('sender'):
|
||||
display_name = msg['sender']
|
||||
break
|
||||
elif msg['direction'] == 'outgoing' and msg.get('recipient'):
|
||||
display_name = msg['recipient']
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'conversation_id': conversation_id,
|
||||
'display_name': display_name,
|
||||
'messages': messages,
|
||||
'count': len(messages)
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting DM messages: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.route('/dm/messages', methods=['POST'])
|
||||
def send_dm_message():
|
||||
"""
|
||||
Send a direct message.
|
||||
|
||||
JSON body:
|
||||
recipient (str): Contact name (required)
|
||||
text (str): Message content (required)
|
||||
|
||||
Returns:
|
||||
JSON with send result:
|
||||
{
|
||||
"success": true,
|
||||
"message": "DM sent",
|
||||
"recipient": "daniel5120",
|
||||
"status": "pending"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Missing JSON body'
|
||||
}), 400
|
||||
|
||||
recipient = data.get('recipient', '').strip()
|
||||
text = data.get('text', '').strip()
|
||||
|
||||
if not recipient:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Missing required field: recipient'
|
||||
}), 400
|
||||
|
||||
if not text:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Missing required field: text'
|
||||
}), 400
|
||||
|
||||
# MeshCore message length limit
|
||||
byte_length = len(text.encode('utf-8'))
|
||||
if byte_length > 200:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Message too long ({byte_length} bytes). Maximum 200 bytes allowed.'
|
||||
}), 400
|
||||
|
||||
# Send via CLI
|
||||
success, message = cli.send_dm(recipient, text)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'DM sent',
|
||||
'recipient': recipient,
|
||||
'status': 'pending'
|
||||
}), 200
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': message
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending DM: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.route('/dm/updates', methods=['GET'])
|
||||
def get_dm_updates():
|
||||
"""
|
||||
Check for new DMs across all conversations.
|
||||
Used for notification badge updates.
|
||||
|
||||
Query params:
|
||||
last_seen (str): JSON object with last seen timestamps per conversation
|
||||
Format: {"pk_xxx": 1234567890, "name_yyy": 1234567891, ...}
|
||||
|
||||
Returns:
|
||||
JSON with update information:
|
||||
{
|
||||
"success": true,
|
||||
"total_unread": 5,
|
||||
"conversations": [
|
||||
{
|
||||
"conversation_id": "pk_4563b1621b58",
|
||||
"display_name": "daniel5120",
|
||||
"unread_count": 3,
|
||||
"latest_timestamp": 1766491173
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Parse last_seen timestamps
|
||||
last_seen_str = request.args.get('last_seen', '{}')
|
||||
try:
|
||||
last_seen = json.loads(last_seen_str)
|
||||
except json.JSONDecodeError:
|
||||
last_seen = {}
|
||||
|
||||
# Get all conversations
|
||||
conversations = parser.get_dm_conversations(days=7)
|
||||
|
||||
updates = []
|
||||
total_unread = 0
|
||||
|
||||
for conv in conversations:
|
||||
conv_id = conv['conversation_id']
|
||||
last_seen_ts = last_seen.get(conv_id, 0)
|
||||
|
||||
# Count unread
|
||||
if conv['last_message_timestamp'] > last_seen_ts:
|
||||
# Need to count actual unread messages
|
||||
messages, _ = parser.read_dm_messages(
|
||||
conversation_id=conv_id,
|
||||
days=7
|
||||
)
|
||||
unread_count = sum(1 for m in messages if m['timestamp'] > last_seen_ts)
|
||||
else:
|
||||
unread_count = 0
|
||||
|
||||
total_unread += unread_count
|
||||
|
||||
if unread_count > 0:
|
||||
updates.append({
|
||||
'conversation_id': conv_id,
|
||||
'display_name': conv['display_name'],
|
||||
'unread_count': unread_count,
|
||||
'latest_timestamp': conv['last_message_timestamp']
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'total_unread': total_unread,
|
||||
'conversations': updates
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking DM updates: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@@ -329,3 +329,156 @@ main {
|
||||
transform: scale(1.1);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Direct Messages (DM) Styles
|
||||
============================================================================= */
|
||||
|
||||
/* DM Badge on notification bell (secondary badge, bottom-right, green) */
|
||||
.notification-badge-dm {
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
right: -6px;
|
||||
background-color: #198754;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
padding: 1px 4px;
|
||||
font-size: 0.6rem;
|
||||
font-weight: bold;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* DM Messages Container */
|
||||
.dm-messages-container {
|
||||
height: 50vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.dm-messages-container {
|
||||
height: calc(100vh - 200px);
|
||||
}
|
||||
}
|
||||
|
||||
/* DM Message Bubbles */
|
||||
.dm-message {
|
||||
max-width: 80%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.9rem;
|
||||
word-wrap: break-word;
|
||||
animation: fadeIn 0.2s ease-in;
|
||||
}
|
||||
|
||||
.dm-message.own {
|
||||
align-self: flex-end;
|
||||
background-color: var(--msg-own-bg);
|
||||
border: 1px solid #b8daff;
|
||||
}
|
||||
|
||||
.dm-message.other {
|
||||
align-self: flex-start;
|
||||
background-color: var(--msg-other-bg);
|
||||
border: 1px solid var(--msg-border);
|
||||
}
|
||||
|
||||
/* DM Message Metadata */
|
||||
.dm-meta {
|
||||
font-size: 0.65rem;
|
||||
color: #adb5bd;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* DM Status Indicators */
|
||||
.dm-status {
|
||||
font-size: 0.7rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.dm-status.pending {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.dm-status.delivered {
|
||||
color: #198754;
|
||||
}
|
||||
|
||||
.dm-status.timeout {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
/* DM Conversation List Item */
|
||||
.dm-conversation-item {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dm-conversation-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.dm-conversation-item.unread {
|
||||
background-color: #e7f1ff;
|
||||
}
|
||||
|
||||
.dm-conversation-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* DM Preview Text */
|
||||
.dm-preview {
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* DM Button on channel messages */
|
||||
.btn-dm {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* DM Empty State */
|
||||
.dm-empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.dm-empty-state i {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* DM Scrollbar */
|
||||
.dm-messages-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.dm-messages-container::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.dm-messages-container::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dm-messages-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #aaa;
|
||||
}
|
||||
|
||||
+457
-3
@@ -12,12 +12,19 @@ let availableChannels = []; // List of channels from API
|
||||
let lastSeenTimestamps = {}; // Track last seen message timestamp per channel
|
||||
let unreadCounts = {}; // Track unread message counts per channel
|
||||
|
||||
// DM state
|
||||
let dmLastSeenTimestamps = {}; // Track last seen DM timestamp per conversation
|
||||
let dmUnreadCounts = {}; // Track unread DM counts per conversation
|
||||
let currentDmConversation = null; // Currently open DM conversation ID
|
||||
let currentDmRecipient = null; // Current DM recipient name
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
console.log('mc-webui initialized');
|
||||
|
||||
// Load last seen timestamps from localStorage
|
||||
loadLastSeenTimestamps();
|
||||
loadDmLastSeenTimestamps();
|
||||
|
||||
// Restore last selected channel from localStorage
|
||||
const savedChannel = localStorage.getItem('mc_active_channel');
|
||||
@@ -222,6 +229,40 @@ function setupEventListeners() {
|
||||
showNotification('QR scanning feature coming soon! For now, manually enter the channel details.', 'info');
|
||||
});
|
||||
|
||||
// DM Modal - load conversations when opened
|
||||
const dmModal = document.getElementById('dmModal');
|
||||
if (dmModal) {
|
||||
dmModal.addEventListener('show.bs.modal', function() {
|
||||
loadDmConversations();
|
||||
closeDmThread(); // Reset to conversation list view
|
||||
});
|
||||
}
|
||||
|
||||
// DM send form
|
||||
const dmSendForm = document.getElementById('dmSendForm');
|
||||
if (dmSendForm) {
|
||||
dmSendForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
await sendDmMessage();
|
||||
});
|
||||
}
|
||||
|
||||
// DM message input - character counter
|
||||
const dmInput = document.getElementById('dmMessageInput');
|
||||
if (dmInput) {
|
||||
dmInput.addEventListener('input', function() {
|
||||
updateDmCharCounter();
|
||||
});
|
||||
|
||||
// Handle Enter key
|
||||
dmInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendDmMessage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Network Commands: Advert button
|
||||
document.getElementById('advertBtn').addEventListener('click', async function() {
|
||||
await executeSpecialCommand('advert');
|
||||
@@ -337,9 +378,16 @@ function createMessageElement(msg) {
|
||||
</div>
|
||||
<p class="message-content">${escapeHtml(msg.content)}</p>
|
||||
${metaInfo ? `<div class="message-meta">${metaInfo}</div>` : ''}
|
||||
${!msg.is_own ? `<button class="btn btn-outline-secondary btn-sm btn-reply" onclick="replyTo('${escapeHtml(msg.sender)}')">
|
||||
<i class="bi bi-reply"></i> Reply
|
||||
</button>` : ''}
|
||||
${!msg.is_own ? `
|
||||
<div class="mt-1">
|
||||
<button class="btn btn-outline-secondary btn-sm btn-reply" onclick="replyTo('${escapeHtml(msg.sender)}')">
|
||||
<i class="bi bi-reply"></i> Reply
|
||||
</button>
|
||||
<button class="btn btn-outline-primary btn-sm btn-dm" onclick="startDmTo('${escapeHtml(msg.sender)}')">
|
||||
<i class="bi bi-envelope"></i> DM
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
return div;
|
||||
@@ -535,6 +583,7 @@ function setupAutoRefresh() {
|
||||
}
|
||||
|
||||
await checkForUpdates();
|
||||
await checkDmUpdates(); // Also check for DM updates
|
||||
}, checkInterval);
|
||||
|
||||
console.log(`Intelligent auto-refresh enabled: checking every ${checkInterval / 1000}s`);
|
||||
@@ -1149,3 +1198,408 @@ async function copyChannelKey() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Direct Messages (DM) Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Load DM last seen timestamps from localStorage
|
||||
*/
|
||||
function loadDmLastSeenTimestamps() {
|
||||
try {
|
||||
const saved = localStorage.getItem('mc_dm_last_seen_timestamps');
|
||||
if (saved) {
|
||||
dmLastSeenTimestamps = JSON.parse(saved);
|
||||
console.log('Loaded DM last seen timestamps:', Object.keys(dmLastSeenTimestamps).length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading DM last seen timestamps:', error);
|
||||
dmLastSeenTimestamps = {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save DM last seen timestamps to localStorage
|
||||
*/
|
||||
function saveDmLastSeenTimestamps() {
|
||||
try {
|
||||
localStorage.setItem('mc_dm_last_seen_timestamps', JSON.stringify(dmLastSeenTimestamps));
|
||||
} catch (error) {
|
||||
console.error('Error saving DM last seen timestamps:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load DM conversations list
|
||||
*/
|
||||
async function loadDmConversations() {
|
||||
const listEl = document.getElementById('dmConversationList');
|
||||
if (!listEl) return;
|
||||
|
||||
listEl.innerHTML = '<div class="text-center py-4"><div class="spinner-border spinner-border-sm"></div> Loading...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/dm/conversations?days=7');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
displayDmConversations(data.conversations);
|
||||
} else {
|
||||
listEl.innerHTML = '<div class="text-center text-danger py-4">Error loading conversations</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading DM conversations:', error);
|
||||
listEl.innerHTML = '<div class="text-center text-danger py-4">Failed to load conversations</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display DM conversations list
|
||||
*/
|
||||
function displayDmConversations(conversations) {
|
||||
const listEl = document.getElementById('dmConversationList');
|
||||
if (!listEl) return;
|
||||
|
||||
if (!conversations || conversations.length === 0) {
|
||||
listEl.innerHTML = `
|
||||
<div class="dm-empty-state">
|
||||
<i class="bi bi-envelope"></i>
|
||||
<p class="mb-1">No direct messages yet</p>
|
||||
<small class="text-muted">Start a conversation by clicking DM on any message</small>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = conversations.map(conv => {
|
||||
const lastSeen = dmLastSeenTimestamps[conv.conversation_id] || 0;
|
||||
const isUnread = conv.last_message_timestamp > lastSeen;
|
||||
|
||||
return `
|
||||
<div class="dm-conversation-item ${isUnread ? 'unread' : ''}"
|
||||
onclick="openDmThread('${escapeHtml(conv.conversation_id)}', '${escapeHtml(conv.display_name)}')">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<strong>${escapeHtml(conv.display_name)}</strong>
|
||||
<small class="text-muted">${formatTime(conv.last_message_timestamp)}</small>
|
||||
</div>
|
||||
<div class="dm-preview">${escapeHtml(conv.last_message_preview)}</div>
|
||||
${isUnread ? '<span class="badge bg-primary mt-1">New</span>' : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a specific DM thread
|
||||
*/
|
||||
async function openDmThread(conversationId, displayName) {
|
||||
currentDmConversation = conversationId;
|
||||
currentDmRecipient = displayName;
|
||||
|
||||
// Show thread view, hide conversation list
|
||||
document.getElementById('dmConversationList').style.display = 'none';
|
||||
document.getElementById('dmThread').style.display = 'block';
|
||||
document.getElementById('dmThreadRecipient').textContent = displayName;
|
||||
|
||||
// Clear input
|
||||
const input = document.getElementById('dmMessageInput');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
updateDmCharCounter();
|
||||
}
|
||||
|
||||
await loadDmMessages(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close DM thread, return to conversation list
|
||||
*/
|
||||
function closeDmThread() {
|
||||
currentDmConversation = null;
|
||||
currentDmRecipient = null;
|
||||
|
||||
const threadEl = document.getElementById('dmThread');
|
||||
const listEl = document.getElementById('dmConversationList');
|
||||
|
||||
if (threadEl) threadEl.style.display = 'none';
|
||||
if (listEl) listEl.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load DM messages for a conversation
|
||||
*/
|
||||
async function loadDmMessages(conversationId) {
|
||||
const listEl = document.getElementById('dmMessagesList');
|
||||
if (!listEl) return;
|
||||
|
||||
listEl.innerHTML = '<div class="text-center py-4"><div class="spinner-border spinner-border-sm"></div></div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/dm/messages?conversation_id=${encodeURIComponent(conversationId)}&limit=100`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
displayDmMessages(data.messages);
|
||||
|
||||
// Update recipient name if we got a better one
|
||||
if (data.display_name && data.display_name !== 'Unknown') {
|
||||
currentDmRecipient = data.display_name;
|
||||
document.getElementById('dmThreadRecipient').textContent = data.display_name;
|
||||
}
|
||||
|
||||
// Mark conversation as read
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
const latestTs = Math.max(...data.messages.map(m => m.timestamp));
|
||||
markDmAsRead(conversationId, latestTs);
|
||||
}
|
||||
} else {
|
||||
listEl.innerHTML = '<div class="text-center text-danger py-4">Error loading messages</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading DM messages:', error);
|
||||
listEl.innerHTML = '<div class="text-center text-danger py-4">Failed to load messages</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display DM messages in thread view
|
||||
*/
|
||||
function displayDmMessages(messages) {
|
||||
const listEl = document.getElementById('dmMessagesList');
|
||||
if (!listEl) return;
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
listEl.innerHTML = `
|
||||
<div class="dm-empty-state">
|
||||
<i class="bi bi-chat-dots"></i>
|
||||
<p>No messages in this conversation</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = '';
|
||||
|
||||
messages.forEach(msg => {
|
||||
const div = document.createElement('div');
|
||||
div.className = `dm-message ${msg.is_own ? 'own' : 'other'}`;
|
||||
|
||||
// Status icon for own messages
|
||||
let statusIcon = '';
|
||||
if (msg.is_own && msg.status) {
|
||||
const icons = {
|
||||
'pending': '<i class="bi bi-clock dm-status pending" title="Sending..."></i>',
|
||||
'delivered': '<i class="bi bi-check2 dm-status delivered" title="Delivered"></i>',
|
||||
'timeout': '<i class="bi bi-x-circle dm-status timeout" title="Not delivered"></i>'
|
||||
};
|
||||
statusIcon = icons[msg.status] || '';
|
||||
}
|
||||
|
||||
// Metadata for incoming messages
|
||||
let meta = '';
|
||||
if (!msg.is_own) {
|
||||
const parts = [];
|
||||
if (msg.snr !== null && msg.snr !== undefined) {
|
||||
parts.push(`SNR: ${msg.snr.toFixed(1)}`);
|
||||
}
|
||||
if (msg.path_len !== null && msg.path_len !== undefined) {
|
||||
parts.push(`Hops: ${msg.path_len}`);
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
meta = `<div class="dm-meta">${parts.join(' | ')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="d-flex justify-content-between align-items-center" style="font-size: 0.7rem;">
|
||||
<span class="text-muted">${formatTime(msg.timestamp)}</span>
|
||||
${statusIcon}
|
||||
</div>
|
||||
<div>${escapeHtml(msg.content)}</div>
|
||||
${meta}
|
||||
`;
|
||||
|
||||
listEl.appendChild(div);
|
||||
});
|
||||
|
||||
// Scroll to bottom
|
||||
listEl.scrollTop = listEl.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a DM message
|
||||
*/
|
||||
async function sendDmMessage() {
|
||||
const input = document.getElementById('dmMessageInput');
|
||||
if (!input) return;
|
||||
|
||||
const text = input.value.trim();
|
||||
if (!text || !currentDmRecipient) return;
|
||||
|
||||
const submitBtn = document.querySelector('#dmSendForm button[type="submit"]');
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/dm/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipient: currentDmRecipient,
|
||||
text: text
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
input.value = '';
|
||||
updateDmCharCounter();
|
||||
showNotification('DM sent', 'success');
|
||||
|
||||
// Reload messages after short delay
|
||||
if (currentDmConversation) {
|
||||
setTimeout(() => loadDmMessages(currentDmConversation), 1000);
|
||||
}
|
||||
} else {
|
||||
showNotification('Failed to send DM: ' + data.error, 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending DM:', error);
|
||||
showNotification('Failed to send DM', 'danger');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start DM from channel message (DM button click)
|
||||
*/
|
||||
function startDmTo(username) {
|
||||
// Open DM modal
|
||||
const modal = new bootstrap.Modal(document.getElementById('dmModal'));
|
||||
modal.show();
|
||||
|
||||
// Open thread view for this user
|
||||
const conversationId = `name_${username}`;
|
||||
setTimeout(() => {
|
||||
openDmThread(conversationId, username);
|
||||
}, 300); // Small delay for modal animation
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for new DMs (called by auto-refresh)
|
||||
*/
|
||||
async function checkDmUpdates() {
|
||||
try {
|
||||
const lastSeenParam = encodeURIComponent(JSON.stringify(dmLastSeenTimestamps));
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(`/api/dm/updates?last_seen=${lastSeenParam}`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Update unread counts
|
||||
dmUnreadCounts = {};
|
||||
if (data.conversations) {
|
||||
data.conversations.forEach(conv => {
|
||||
dmUnreadCounts[conv.conversation_id] = conv.unread_count;
|
||||
});
|
||||
}
|
||||
|
||||
// Update badges
|
||||
updateDmBadges(data.total_unread || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Error checking DM updates:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update DM notification badges
|
||||
*/
|
||||
function updateDmBadges(totalUnread) {
|
||||
// Update menu badge
|
||||
const menuBadge = document.getElementById('dmMenuBadge');
|
||||
if (menuBadge) {
|
||||
if (totalUnread > 0) {
|
||||
menuBadge.textContent = totalUnread > 99 ? '99+' : totalUnread;
|
||||
menuBadge.style.display = 'inline-block';
|
||||
} else {
|
||||
menuBadge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Update notification bell (secondary badge)
|
||||
const bellContainer = document.getElementById('notificationBell');
|
||||
if (!bellContainer) return;
|
||||
|
||||
let dmBadge = bellContainer.querySelector('.notification-badge-dm');
|
||||
|
||||
if (totalUnread > 0) {
|
||||
if (!dmBadge) {
|
||||
dmBadge = document.createElement('span');
|
||||
dmBadge.className = 'notification-badge-dm';
|
||||
bellContainer.appendChild(dmBadge);
|
||||
}
|
||||
dmBadge.textContent = totalUnread > 99 ? '99+' : totalUnread;
|
||||
dmBadge.style.display = 'inline-block';
|
||||
|
||||
// Animate bell
|
||||
const bellIcon = bellContainer.querySelector('i');
|
||||
if (bellIcon) {
|
||||
bellIcon.classList.add('bell-ring');
|
||||
setTimeout(() => bellIcon.classList.remove('bell-ring'), 1000);
|
||||
}
|
||||
} else if (dmBadge) {
|
||||
dmBadge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark DM conversation as read
|
||||
*/
|
||||
function markDmAsRead(conversationId, timestamp) {
|
||||
dmLastSeenTimestamps[conversationId] = timestamp;
|
||||
dmUnreadCounts[conversationId] = 0;
|
||||
saveDmLastSeenTimestamps();
|
||||
|
||||
// Recalculate total unread
|
||||
const totalUnread = Object.values(dmUnreadCounts).reduce((sum, count) => sum + count, 0);
|
||||
updateDmBadges(totalUnread);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update DM character counter
|
||||
*/
|
||||
function updateDmCharCounter() {
|
||||
const input = document.getElementById('dmMessageInput');
|
||||
const counter = document.getElementById('dmCharCounter');
|
||||
if (!input || !counter) return;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const byteLength = encoder.encode(input.value).length;
|
||||
counter.textContent = byteLength;
|
||||
|
||||
// Visual warning
|
||||
if (byteLength > 180) {
|
||||
counter.classList.add('text-danger');
|
||||
} else if (byteLength > 150) {
|
||||
counter.classList.remove('text-danger');
|
||||
counter.classList.add('text-warning');
|
||||
} else {
|
||||
counter.classList.remove('text-danger', 'text-warning');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,13 @@
|
||||
<i class="bi bi-broadcast-pin" style="font-size: 1.5rem;"></i>
|
||||
<span>Manage Channels</span>
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#dmModal" data-bs-dismiss="offcanvas">
|
||||
<i class="bi bi-envelope" style="font-size: 1.5rem;"></i>
|
||||
<div class="d-flex flex-grow-1 justify-content-between align-items-center">
|
||||
<span>Direct Messages</span>
|
||||
<span id="dmMenuBadge" class="badge bg-success rounded-pill" style="display: none;">0</span>
|
||||
</div>
|
||||
</button>
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex align-items-center gap-3 mb-2">
|
||||
<i class="bi bi-calendar3" style="font-size: 1.5rem;"></i>
|
||||
@@ -255,6 +262,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Direct Messages Modal -->
|
||||
<div class="modal fade" id="dmModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-fullscreen-sm-down">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-envelope"></i> Direct Messages</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<!-- Conversation list (shown when no conversation selected) -->
|
||||
<div id="dmConversationList">
|
||||
<div class="text-center text-muted py-4">
|
||||
<div class="spinner-border spinner-border-sm"></div> Loading...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversation thread (shown when conversation selected) -->
|
||||
<div id="dmThread" style="display: none;">
|
||||
<div class="p-2 bg-light border-bottom d-flex align-items-center">
|
||||
<button class="btn btn-sm btn-outline-secondary me-2" onclick="closeDmThread()">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
<strong id="dmThreadRecipient"></strong>
|
||||
</div>
|
||||
<div id="dmMessagesList" class="dm-messages-container">
|
||||
<!-- Messages loaded here -->
|
||||
</div>
|
||||
<div class="p-2 border-top">
|
||||
<form id="dmSendForm" class="d-flex gap-2">
|
||||
<input type="text" id="dmMessageInput" class="form-control form-control-sm"
|
||||
placeholder="Type a message..." maxlength="200">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-send"></i>
|
||||
</button>
|
||||
</form>
|
||||
<div class="d-flex justify-content-end mt-1">
|
||||
<small class="text-muted"><span id="dmCharCounter">0</span>/200</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast container for notifications -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div id="notificationToast" class="toast" role="alert">
|
||||
|
||||
Reference in New Issue
Block a user