mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-06 16:53:21 +02:00
refactor(dm): Replace modal with full-page view and fix sent DM tracking
- Replace DM modal with full-page view at /dm route for better mobile UX - Add workaround for meshcore-cli bug where SENT_MSG contains sender's name instead of recipient - now saving sent DMs to separate log file - Fix DM button styling to match Reply button (btn-outline-secondary) - Add dm.js for DM page functionality - Add dm.html template with green navbar for visual distinction - Update menu link to navigate to /dm instead of opening modal - Remove unused DM modal functions from app.js - Update documentation with new DM workflow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -140,10 +140,12 @@ mc-webui/
|
||||
│ │ ├── css/
|
||||
│ │ │ └── style.css # Custom styles
|
||||
│ │ └── js/
|
||||
│ │ └── app.js # Frontend logic
|
||||
│ │ ├── app.js # Main page frontend logic
|
||||
│ │ └── dm.js # Direct Messages page logic
|
||||
│ └── templates/
|
||||
│ ├── base.html # Base template
|
||||
│ ├── index.html # Main chat view
|
||||
│ ├── dm.html # Direct Messages full-page view
|
||||
│ └── components/ # Reusable components
|
||||
├── requirements.txt # Python dependencies
|
||||
├── .env.example # Example environment config
|
||||
@@ -254,20 +256,22 @@ Click the reply button on any message to insert `@[UserName]` into the text fiel
|
||||
|
||||
### Direct Messages (DM)
|
||||
|
||||
Access the Direct Messages feature from the slide-out menu:
|
||||
Access the Direct Messages feature:
|
||||
|
||||
**From the menu:**
|
||||
1. Click the menu icon (☰) in the navbar
|
||||
2. Select "Direct Messages" from the menu
|
||||
3. View your conversation list with unread indicators
|
||||
3. Opens a dedicated full-page DM view
|
||||
|
||||
**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
|
||||
**From channel messages:**
|
||||
- Click the "DM" button next to any message to start a private chat with that user
|
||||
- You'll be redirected to the DM page with that conversation selected
|
||||
|
||||
**Sending a direct message:**
|
||||
1. Open a conversation
|
||||
2. Type your message (max 200 characters)
|
||||
**Using the DM page:**
|
||||
1. Select a conversation from the dropdown at the top (or one opens automatically if started from a message)
|
||||
2. Type your message in the input field (max 200 bytes)
|
||||
3. Press Enter or click Send
|
||||
4. Click "Back" button to return to the main chat view
|
||||
|
||||
**Message status indicators:**
|
||||
- ⏳ **Pending** (yellow) - Message sent, waiting for delivery confirmation
|
||||
@@ -275,7 +279,7 @@ Access the Direct Messages feature from the slide-out menu:
|
||||
|
||||
**Notifications:**
|
||||
- The bell icon shows a secondary green badge for unread DMs
|
||||
- Each conversation shows unread count in the conversation list
|
||||
- Each conversation shows unread indicator (*) in the dropdown
|
||||
- DM badge in the menu shows total unread DM count
|
||||
|
||||
### Managing Contacts
|
||||
|
||||
@@ -42,6 +42,11 @@ class Config:
|
||||
"""Get the full path to archive directory"""
|
||||
return Path(self.MC_ARCHIVE_DIR)
|
||||
|
||||
@property
|
||||
def dm_sent_log_path(self) -> Path:
|
||||
"""Get the full path to our sent DM log file (workaround for meshcore-cli bug)"""
|
||||
return Path(self.MC_CONFIG_DIR) / f"{self.MC_DEVICE_NAME}_dm_sent.jsonl"
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"Config(device={self.MC_DEVICE_NAME}, "
|
||||
|
||||
+151
-110
@@ -313,6 +313,73 @@ def delete_channel_messages(channel_idx: int) -> bool:
|
||||
# =============================================================================
|
||||
# Direct Messages (DM) Parsing
|
||||
# =============================================================================
|
||||
#
|
||||
# Note: meshcore-cli has a bug where SENT_MSG entries contain the sender's
|
||||
# device name instead of the recipient's name. To work around this, we maintain
|
||||
# our own sent DM log file with correct recipient information.
|
||||
# See: https://github.com/liamcottle/meshcore-cli/issues/XXX
|
||||
# =============================================================================
|
||||
|
||||
def save_sent_dm(recipient: str, text: str) -> bool:
|
||||
"""
|
||||
Save a sent DM to our own log file (workaround for meshcore-cli bug).
|
||||
|
||||
Args:
|
||||
recipient: Contact name the message was sent to
|
||||
text: Message content
|
||||
|
||||
Returns:
|
||||
True if saved successfully, False otherwise
|
||||
"""
|
||||
dm_log_file = config.dm_sent_log_path
|
||||
|
||||
entry = {
|
||||
'timestamp': int(time.time()),
|
||||
'recipient': recipient,
|
||||
'text': text,
|
||||
'status': 'pending'
|
||||
}
|
||||
|
||||
try:
|
||||
with open(dm_log_file, 'a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
||||
logger.info(f"Saved sent DM to {recipient}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving sent DM: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _read_sent_dm_log() -> List[Dict]:
|
||||
"""
|
||||
Read sent DMs from our own log file.
|
||||
|
||||
Returns:
|
||||
List of sent DM entries
|
||||
"""
|
||||
dm_log_file = config.dm_sent_log_path
|
||||
|
||||
if not dm_log_file.exists():
|
||||
return []
|
||||
|
||||
entries = []
|
||||
try:
|
||||
with open(dm_log_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)
|
||||
entries.append(data)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Invalid JSON in DM log at line {line_num}: {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading sent DM log: {e}")
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def _parse_priv_message(line: Dict) -> Optional[Dict]:
|
||||
"""
|
||||
@@ -361,41 +428,32 @@ def _parse_priv_message(line: Dict) -> Optional[Dict]:
|
||||
}
|
||||
|
||||
|
||||
def _parse_sent_msg(line: Dict) -> Optional[Dict]:
|
||||
def _parse_sent_dm_entry(entry: Dict) -> Optional[Dict]:
|
||||
"""
|
||||
Parse outgoing private message (SENT_MSG type).
|
||||
Parse a sent DM entry from our own log file.
|
||||
|
||||
Args:
|
||||
line: Raw JSON object from .msgs file with type='SENT_MSG'
|
||||
entry: Entry from our dm_sent.jsonl file
|
||||
|
||||
Returns:
|
||||
Parsed DM dict or None if invalid
|
||||
"""
|
||||
text = line.get('text', '').strip()
|
||||
text = entry.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
|
||||
timestamp = entry.get('timestamp', 0)
|
||||
recipient = entry.get('recipient', 'Unknown')
|
||||
|
||||
# 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}"
|
||||
# Deduplication key
|
||||
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'
|
||||
# Status is always timeout for old messages (we don't have ACK tracking)
|
||||
status = 'timeout'
|
||||
|
||||
return {
|
||||
'type': 'dm',
|
||||
@@ -405,42 +463,22 @@ def _parse_sent_msg(line: Dict) -> Optional[Dict]:
|
||||
'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.
|
||||
Read and parse DM messages from .msgs file (incoming) and our sent DM log (outgoing).
|
||||
|
||||
Note: We ignore SENT_MSG entries from .msgs because they have the wrong recipient
|
||||
due to a bug in meshcore-cli.
|
||||
|
||||
Args:
|
||||
limit: Maximum messages to return (None = all)
|
||||
@@ -451,84 +489,87 @@ def read_dm_messages(
|
||||
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:
|
||||
# --- Read incoming messages (PRIV) from .msgs file ---
|
||||
msgs_file = config.msgs_file_path
|
||||
if msgs_file.exists():
|
||||
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
|
||||
|
||||
# Update pubkey->name mapping from incoming messages
|
||||
if parsed['direction'] == 'incoming' and parsed.get('pubkey_prefix'):
|
||||
pubkey_to_name[parsed['pubkey_prefix']] = parsed['sender']
|
||||
try:
|
||||
data = json.loads(line)
|
||||
|
||||
# Deduplicate
|
||||
if parsed['dedup_key'] in seen_dedup_keys:
|
||||
# Only process PRIV messages (incoming DMs)
|
||||
if data.get('type') != 'PRIV':
|
||||
continue
|
||||
|
||||
parsed = _parse_priv_message(data)
|
||||
if not parsed:
|
||||
continue
|
||||
|
||||
# Update pubkey->name mapping
|
||||
if 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'])
|
||||
|
||||
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
|
||||
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
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading messages file: {e}")
|
||||
|
||||
messages.append(parsed)
|
||||
# --- Read sent DMs from our own log file ---
|
||||
sent_entries = _read_sent_dm_log()
|
||||
for entry in sent_entries:
|
||||
parsed = _parse_sent_dm_entry(entry)
|
||||
if not parsed:
|
||||
continue
|
||||
|
||||
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
|
||||
# Deduplicate
|
||||
if parsed['dedup_key'] in seen_dedup_keys:
|
||||
continue
|
||||
seen_dedup_keys.add(parsed['dedup_key'])
|
||||
|
||||
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 [], {}
|
||||
messages.append(parsed)
|
||||
|
||||
# --- Filter by conversation if specified ---
|
||||
if conversation_id:
|
||||
filtered_messages = []
|
||||
for msg in messages:
|
||||
if msg['conversation_id'] == conversation_id:
|
||||
filtered_messages.append(msg)
|
||||
else:
|
||||
# Check if it matches via pubkey->name mapping
|
||||
if conversation_id.startswith('pk_'):
|
||||
pk = conversation_id[3:]
|
||||
name = pubkey_to_name.get(pk)
|
||||
if name and msg['conversation_id'] == f"name_{name}":
|
||||
filtered_messages.append(msg)
|
||||
elif conversation_id.startswith('name_'):
|
||||
name = conversation_id[5:]
|
||||
# Check if any pubkey maps to this name
|
||||
for pk, n in pubkey_to_name.items():
|
||||
if n == name and msg['conversation_id'] == f"pk_{pk}":
|
||||
filtered_messages.append(msg)
|
||||
break
|
||||
messages = filtered_messages
|
||||
|
||||
# Sort by timestamp (oldest first)
|
||||
messages.sort(key=lambda m: m['timestamp'])
|
||||
|
||||
@@ -1069,6 +1069,9 @@ def send_dm_message():
|
||||
success, message = cli.send_dm(recipient, text)
|
||||
|
||||
if success:
|
||||
# Save to our own sent DM log (workaround for meshcore-cli bug)
|
||||
parser.save_sent_dm(recipient, text)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'DM sent',
|
||||
|
||||
+19
-1
@@ -3,7 +3,7 @@ HTML views for mc-webui
|
||||
"""
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, render_template
|
||||
from flask import Blueprint, render_template, request
|
||||
from app.config import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,6 +23,24 @@ def index():
|
||||
)
|
||||
|
||||
|
||||
@views_bp.route('/dm')
|
||||
def direct_messages():
|
||||
"""
|
||||
Direct Messages view - full-page DM interface.
|
||||
|
||||
Query params:
|
||||
conversation: Optional conversation ID to open initially
|
||||
"""
|
||||
initial_conversation = request.args.get('conversation', '')
|
||||
|
||||
return render_template(
|
||||
'dm.html',
|
||||
device_name=config.MC_DEVICE_NAME,
|
||||
refresh_interval=config.MC_REFRESH_INTERVAL,
|
||||
initial_conversation=initial_conversation
|
||||
)
|
||||
|
||||
|
||||
@views_bp.route('/health')
|
||||
def health():
|
||||
"""
|
||||
|
||||
@@ -445,12 +445,7 @@ main {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* DM Button on channel messages */
|
||||
.btn-dm {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
/* DM Button on channel messages - uses same class as Reply (.btn-reply) */
|
||||
|
||||
/* DM Empty State */
|
||||
.dm-empty-state {
|
||||
|
||||
+4
-324
@@ -12,11 +12,9 @@ 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
|
||||
// DM state (for badge updates on main page)
|
||||
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() {
|
||||
@@ -229,40 +227,6 @@ 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');
|
||||
@@ -383,7 +347,7 @@ function createMessageElement(msg) {
|
||||
<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)}')">
|
||||
<button class="btn btn-outline-secondary btn-sm btn-reply" onclick="startDmTo('${escapeHtml(msg.sender)}')">
|
||||
<i class="bi bi-envelope"></i> DM
|
||||
</button>
|
||||
</div>
|
||||
@@ -1231,262 +1195,13 @@ function saveDmLastSeenTimestamps() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* Redirects to the full-page DM view
|
||||
*/
|
||||
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
|
||||
window.location.href = `/dm?conversation=${encodeURIComponent(conversationId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1568,38 +1283,3 @@ function updateDmBadges(totalUnread) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* mc-webui Direct Messages JavaScript
|
||||
* Full-page DM view functionality
|
||||
*/
|
||||
|
||||
// State variables
|
||||
let currentConversationId = null;
|
||||
let currentRecipient = null;
|
||||
let dmConversations = [];
|
||||
let dmLastSeenTimestamps = {};
|
||||
let autoRefreshInterval = null;
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
console.log('DM page initialized');
|
||||
|
||||
// Load last seen timestamps from localStorage
|
||||
loadDmLastSeenTimestamps();
|
||||
|
||||
// Setup event listeners
|
||||
setupEventListeners();
|
||||
|
||||
// Load conversations into dropdown
|
||||
await loadConversations();
|
||||
|
||||
// Check for initial conversation from URL parameter
|
||||
if (window.MC_CONFIG && window.MC_CONFIG.initialConversation) {
|
||||
const convId = window.MC_CONFIG.initialConversation;
|
||||
// Find the conversation in the list or use the ID directly
|
||||
selectConversation(convId);
|
||||
}
|
||||
|
||||
// Setup auto-refresh
|
||||
setupAutoRefresh();
|
||||
|
||||
updateStatus('connected', 'Ready');
|
||||
});
|
||||
|
||||
/**
|
||||
* Setup event listeners
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
// Conversation selector
|
||||
const selector = document.getElementById('dmConversationSelector');
|
||||
if (selector) {
|
||||
selector.addEventListener('change', function() {
|
||||
const convId = this.value;
|
||||
if (convId) {
|
||||
selectConversation(convId);
|
||||
} else {
|
||||
clearConversation();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Send form
|
||||
const sendForm = document.getElementById('dmSendForm');
|
||||
if (sendForm) {
|
||||
sendForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
await sendMessage();
|
||||
});
|
||||
}
|
||||
|
||||
// Message input
|
||||
const input = document.getElementById('dmMessageInput');
|
||||
if (input) {
|
||||
input.addEventListener('input', updateCharCounter);
|
||||
|
||||
// Enter key to send
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load conversations from API
|
||||
*/
|
||||
async function loadConversations() {
|
||||
try {
|
||||
const response = await fetch('/api/dm/conversations?days=7');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
dmConversations = data.conversations || [];
|
||||
populateConversationSelector();
|
||||
} else {
|
||||
console.error('Failed to load conversations:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading conversations:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the conversation selector dropdown
|
||||
*/
|
||||
function populateConversationSelector() {
|
||||
const selector = document.getElementById('dmConversationSelector');
|
||||
if (!selector) return;
|
||||
|
||||
// Clear existing options (keep first placeholder)
|
||||
selector.innerHTML = '<option value="">Select chat...</option>';
|
||||
|
||||
if (dmConversations.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = 'No conversations yet';
|
||||
opt.disabled = true;
|
||||
selector.appendChild(opt);
|
||||
return;
|
||||
}
|
||||
|
||||
dmConversations.forEach(conv => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = conv.conversation_id;
|
||||
|
||||
// Show unread indicator
|
||||
const lastSeen = dmLastSeenTimestamps[conv.conversation_id] || 0;
|
||||
const isUnread = conv.last_message_timestamp > lastSeen;
|
||||
|
||||
let label = conv.display_name;
|
||||
if (isUnread) {
|
||||
label = `* ${label}`;
|
||||
}
|
||||
|
||||
opt.textContent = label;
|
||||
selector.appendChild(opt);
|
||||
});
|
||||
|
||||
// If we have a current conversation, select it
|
||||
if (currentConversationId) {
|
||||
selector.value = currentConversationId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a conversation
|
||||
*/
|
||||
async function selectConversation(conversationId) {
|
||||
currentConversationId = conversationId;
|
||||
|
||||
// Find the conversation to get recipient name
|
||||
const conv = dmConversations.find(c => c.conversation_id === conversationId);
|
||||
if (conv) {
|
||||
currentRecipient = conv.display_name;
|
||||
} else {
|
||||
// Extract name from conversation_id
|
||||
if (conversationId.startsWith('name_')) {
|
||||
currentRecipient = conversationId.substring(5);
|
||||
} else if (conversationId.startsWith('pk_')) {
|
||||
currentRecipient = conversationId.substring(3, 11) + '...';
|
||||
} else {
|
||||
currentRecipient = 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// Update selector if not already selected
|
||||
const selector = document.getElementById('dmConversationSelector');
|
||||
if (selector && selector.value !== conversationId) {
|
||||
selector.value = conversationId;
|
||||
}
|
||||
|
||||
// Enable input
|
||||
const input = document.getElementById('dmMessageInput');
|
||||
const sendBtn = document.getElementById('dmSendBtn');
|
||||
if (input) {
|
||||
input.disabled = false;
|
||||
input.placeholder = `Message ${currentRecipient}...`;
|
||||
}
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Load messages
|
||||
await loadMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear conversation selection
|
||||
*/
|
||||
function clearConversation() {
|
||||
currentConversationId = null;
|
||||
currentRecipient = null;
|
||||
|
||||
// Disable input
|
||||
const input = document.getElementById('dmMessageInput');
|
||||
const sendBtn = document.getElementById('dmSendBtn');
|
||||
if (input) {
|
||||
input.disabled = true;
|
||||
input.placeholder = 'Type a message...';
|
||||
input.value = '';
|
||||
}
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = true;
|
||||
}
|
||||
|
||||
// Show empty state
|
||||
const container = document.getElementById('dmMessagesList');
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="dm-empty-state">
|
||||
<i class="bi bi-envelope"></i>
|
||||
<p class="mb-1">Select a conversation</p>
|
||||
<small class="text-muted">Choose from the dropdown above or start a new chat from channel messages</small>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
updateCharCounter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages for current conversation
|
||||
*/
|
||||
async function loadMessages() {
|
||||
if (!currentConversationId) return;
|
||||
|
||||
const container = document.getElementById('dmMessagesList');
|
||||
if (!container) return;
|
||||
|
||||
container.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(currentConversationId)}&limit=100`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
displayMessages(data.messages);
|
||||
|
||||
// Update recipient if we got a better name
|
||||
if (data.display_name && data.display_name !== 'Unknown') {
|
||||
currentRecipient = data.display_name;
|
||||
const input = document.getElementById('dmMessageInput');
|
||||
if (input) {
|
||||
input.placeholder = `Message ${currentRecipient}...`;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as read
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
const latestTs = Math.max(...data.messages.map(m => m.timestamp));
|
||||
markAsRead(currentConversationId, latestTs);
|
||||
}
|
||||
|
||||
updateLastRefresh();
|
||||
} else {
|
||||
container.innerHTML = '<div class="text-center text-danger py-4">Error loading messages</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading messages:', error);
|
||||
container.innerHTML = '<div class="text-center text-danger py-4">Failed to load messages</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display messages in the container
|
||||
*/
|
||||
function displayMessages(messages) {
|
||||
const container = document.getElementById('dmMessagesList');
|
||||
if (!container) return;
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="dm-empty-state">
|
||||
<i class="bi bi-chat-dots"></i>
|
||||
<p>No messages yet</p>
|
||||
<small class="text-muted">Send a message to start the conversation</small>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.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}
|
||||
`;
|
||||
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
// Scroll to bottom
|
||||
const scrollContainer = document.getElementById('dmMessagesContainer');
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message
|
||||
*/
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById('dmMessageInput');
|
||||
if (!input) return;
|
||||
|
||||
const text = input.value.trim();
|
||||
if (!text || !currentRecipient) return;
|
||||
|
||||
const sendBtn = document.getElementById('dmSendBtn');
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/dm/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipient: currentRecipient,
|
||||
text: text
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
input.value = '';
|
||||
updateCharCounter();
|
||||
showNotification('Message sent', 'success');
|
||||
|
||||
// Reload messages after short delay
|
||||
setTimeout(() => loadMessages(), 1000);
|
||||
} else {
|
||||
showNotification('Failed to send: ' + data.error, 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
showNotification('Failed to send message', 'danger');
|
||||
} finally {
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup auto-refresh
|
||||
*/
|
||||
function setupAutoRefresh() {
|
||||
const checkInterval = 10000; // 10 seconds
|
||||
|
||||
autoRefreshInterval = setInterval(async () => {
|
||||
// Reload conversations to update unread indicators
|
||||
await loadConversations();
|
||||
|
||||
// If viewing a conversation, reload messages
|
||||
if (currentConversationId) {
|
||||
await loadMessages();
|
||||
}
|
||||
}, checkInterval);
|
||||
|
||||
console.log('Auto-refresh enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update character counter
|
||||
*/
|
||||
function updateCharCounter() {
|
||||
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;
|
||||
|
||||
if (byteLength > 180) {
|
||||
counter.classList.add('text-danger');
|
||||
counter.classList.remove('text-warning');
|
||||
} else if (byteLength > 150) {
|
||||
counter.classList.remove('text-danger');
|
||||
counter.classList.add('text-warning');
|
||||
} else {
|
||||
counter.classList.remove('text-danger', 'text-warning');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading 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 last seen timestamps:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark conversation as read
|
||||
*/
|
||||
function markAsRead(conversationId, timestamp) {
|
||||
dmLastSeenTimestamps[conversationId] = timestamp;
|
||||
saveDmLastSeenTimestamps();
|
||||
|
||||
// Update dropdown to remove unread indicator
|
||||
populateConversationSelector();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status indicator
|
||||
*/
|
||||
function updateStatus(status, message) {
|
||||
const statusEl = document.getElementById('dmStatusText');
|
||||
if (!statusEl) return;
|
||||
|
||||
const statusColors = {
|
||||
'connected': 'success',
|
||||
'disconnected': 'danger',
|
||||
'connecting': 'warning'
|
||||
};
|
||||
|
||||
const color = statusColors[status] || 'secondary';
|
||||
statusEl.innerHTML = `<i class="bi bi-circle-fill text-${color}"></i> ${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last refresh time
|
||||
*/
|
||||
function updateLastRefresh() {
|
||||
const el = document.getElementById('dmLastRefresh');
|
||||
if (el) {
|
||||
el.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to readable time
|
||||
*/
|
||||
function formatTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
|
||||
if (isToday) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) +
|
||||
' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast notification
|
||||
*/
|
||||
function showNotification(message, type = 'info') {
|
||||
const toastEl = document.getElementById('notificationToast');
|
||||
if (!toastEl) return;
|
||||
|
||||
const toastBody = toastEl.querySelector('.toast-body');
|
||||
if (toastBody) {
|
||||
toastBody.textContent = message;
|
||||
}
|
||||
|
||||
// Update toast header color based on type
|
||||
const toastHeader = toastEl.querySelector('.toast-header');
|
||||
if (toastHeader) {
|
||||
toastHeader.className = 'toast-header';
|
||||
if (type === 'success') {
|
||||
toastHeader.classList.add('bg-success', 'text-white');
|
||||
} else if (type === 'danger') {
|
||||
toastHeader.classList.add('bg-danger', 'text-white');
|
||||
} else if (type === 'warning') {
|
||||
toastHeader.classList.add('bg-warning');
|
||||
}
|
||||
}
|
||||
|
||||
const toast = new bootstrap.Toast(toastEl, { delay: 3000 });
|
||||
toast.show();
|
||||
}
|
||||
+2
-47
@@ -63,13 +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">
|
||||
<a href="/dm" class="list-group-item list-group-item-action d-flex align-items-center gap-3" 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>
|
||||
</a>
|
||||
<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>
|
||||
@@ -262,51 +262,6 @@
|
||||
</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">
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>Direct Messages - mc-webui</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
||||
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css">
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navbar for DM page -->
|
||||
<nav class="navbar navbar-dark bg-success">
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<a href="/" class="btn btn-outline-light btn-sm" title="Back to Channels">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<span class="navbar-brand mb-0 h1">
|
||||
<i class="bi bi-envelope"></i> Direct Messages
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select id="dmConversationSelector" class="form-select form-select-sm" style="width: auto; min-width: 120px;" title="Select conversation">
|
||||
<option value="">Select chat...</option>
|
||||
<!-- Conversations loaded dynamically via JavaScript -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main>
|
||||
<div class="container-fluid d-flex flex-column" style="height: 100%;">
|
||||
<!-- Messages Container -->
|
||||
<div class="row flex-grow-1 overflow-hidden" style="min-height: 0;">
|
||||
<div class="col-12" style="height: 100%;">
|
||||
<div id="dmMessagesContainer" class="messages-container h-100 overflow-auto p-3">
|
||||
<div id="dmMessagesList">
|
||||
<!-- Placeholder shown when no conversation selected -->
|
||||
<div class="dm-empty-state">
|
||||
<i class="bi bi-envelope"></i>
|
||||
<p class="mb-1">Select a conversation</p>
|
||||
<small class="text-muted">Choose from the dropdown above or start a new chat from channel messages</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Message Form -->
|
||||
<div class="row border-top bg-light">
|
||||
<div class="col-12">
|
||||
<form id="dmSendForm" class="p-3">
|
||||
<div class="input-group">
|
||||
<input type="text"
|
||||
id="dmMessageInput"
|
||||
class="form-control"
|
||||
placeholder="Type a message..."
|
||||
maxlength="200"
|
||||
disabled>
|
||||
<button type="submit" class="btn btn-success px-4" id="dmSendBtn" disabled>
|
||||
<i class="bi bi-send"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<small class="text-muted"><span id="dmCharCounter">0</span> / 200</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="row border-top">
|
||||
<div class="col-12">
|
||||
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
|
||||
<span id="dmStatusText">
|
||||
<i class="bi bi-circle-fill text-secondary"></i> Ready
|
||||
</span>
|
||||
<span id="dmLastRefresh">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Toast container for notifications -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div id="notificationToast" class="toast" role="alert">
|
||||
<div class="toast-header">
|
||||
<strong class="me-auto">mc-webui</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="toast-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap 5 JS Bundle -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Custom JS -->
|
||||
<script src="{{ url_for('static', filename='js/dm.js') }}"></script>
|
||||
|
||||
<script>
|
||||
// Pass configuration from Flask to JavaScript
|
||||
window.MC_CONFIG = {
|
||||
refreshInterval: {{ refresh_interval }} * 1000,
|
||||
deviceName: "{{ device_name }}",
|
||||
initialConversation: "{{ initial_conversation or '' }}"
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user