mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-05-02 03:22:40 +02:00
feat(dm): searchable contact selector, contact info modal, device-only contacts
Redesign DM chat contact selector: - Replace <select> dropdown with searchable text input + filtered dropdown - Show only device contacts (from /api/contacts/detailed), not all cached - Sort contacts alphabetically, conversations by recency - Type badge (CLI/REP/ROOM/SENS) shown in dropdown items - Keyboard support: Enter selects first match, Escape closes Add Contact Info modal (replaces Retry toggle in header): - Shows contact name, type, public key, last advert, path/route, GPS - Auto Retry toggle moved into modal footer - Designed for future extensibility (manual path setting etc.) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -322,7 +322,7 @@ main {
|
||||
|
||||
/* Navbar: Channel selector on mobile */
|
||||
#channelSelector,
|
||||
#dmConversationSelector {
|
||||
#dmContactSearchInput {
|
||||
min-width: 100px !important;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
let currentConversationId = null;
|
||||
let currentRecipient = null;
|
||||
let dmConversations = [];
|
||||
let contactsList = []; // List of all contacts from device
|
||||
let contactsList = []; // List of detailed contact objects from device
|
||||
let contactsMap = {}; // Map of public_key -> contact object
|
||||
let dmLastSeenTimestamps = {};
|
||||
let autoRefreshInterval = null;
|
||||
let lastMessageTimestamp = 0; // Track latest message timestamp for smart refresh
|
||||
@@ -172,17 +173,48 @@ document.addEventListener('visibilitychange', function() {
|
||||
* 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();
|
||||
// Searchable contact input
|
||||
const searchInput = document.getElementById('dmContactSearchInput');
|
||||
const contactDropdown = document.getElementById('dmContactDropdown');
|
||||
const searchWrapper = document.getElementById('dmContactSearchWrapper');
|
||||
|
||||
if (searchInput && contactDropdown) {
|
||||
searchInput.addEventListener('focus', () => {
|
||||
renderDropdownItems(searchInput.value);
|
||||
contactDropdown.style.display = 'block';
|
||||
});
|
||||
|
||||
searchInput.addEventListener('input', () => {
|
||||
renderDropdownItems(searchInput.value);
|
||||
contactDropdown.style.display = 'block';
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (searchWrapper && !searchWrapper.contains(e.target)) {
|
||||
contactDropdown.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
contactDropdown.style.display = 'none';
|
||||
searchInput.blur();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const first = contactDropdown.querySelector('.dm-contact-item');
|
||||
if (first) first.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Contact info button
|
||||
const infoBtn = document.getElementById('dmContactInfoBtn');
|
||||
if (infoBtn) {
|
||||
infoBtn.addEventListener('click', () => {
|
||||
const modal = new bootstrap.Modal(document.getElementById('dmContactInfoModal'));
|
||||
populateContactInfoModal();
|
||||
modal.show();
|
||||
});
|
||||
}
|
||||
|
||||
// Send form
|
||||
@@ -233,19 +265,26 @@ function setupEventListeners() {
|
||||
*/
|
||||
async function loadContacts() {
|
||||
try {
|
||||
const response = await fetch('/api/contacts');
|
||||
const response = await fetch('/api/contacts/detailed');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
contactsList = data.contacts || [];
|
||||
console.log(`[DM] Loaded ${contactsList.length} contacts:`, contactsList);
|
||||
contactsList = (data.contacts || []).sort((a, b) =>
|
||||
(a.name || '').localeCompare(b.name || ''));
|
||||
contactsMap = {};
|
||||
contactsList.forEach(c => {
|
||||
if (c.public_key) contactsMap[c.public_key] = c;
|
||||
});
|
||||
console.log(`[DM] Loaded ${contactsList.length} device contacts`);
|
||||
} else {
|
||||
console.error('[DM] Failed to load contacts:', data.error);
|
||||
contactsList = [];
|
||||
contactsMap = {};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DM] Error loading contacts:', error);
|
||||
contactsList = [];
|
||||
contactsMap = {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,79 +318,146 @@ async function loadConversations() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the conversation selector dropdown
|
||||
* Shows both existing conversations and all contacts
|
||||
* Populate the searchable conversation dropdown data.
|
||||
* Two sections: recent conversations (by recency) + device contacts (alphabetical).
|
||||
*/
|
||||
function populateConversationSelector() {
|
||||
const selector = document.getElementById('dmConversationSelector');
|
||||
if (!selector) return;
|
||||
// Build conversation entries with contact data
|
||||
const convPubkeyPrefixes = new Set();
|
||||
const conversations = dmConversations.map(conv => {
|
||||
// Extract pubkey prefix from conversation_id
|
||||
let pkPrefix = '';
|
||||
if (conv.conversation_id.startsWith('pk_')) {
|
||||
pkPrefix = conv.conversation_id.substring(3);
|
||||
}
|
||||
convPubkeyPrefixes.add(pkPrefix);
|
||||
|
||||
// Clear existing options
|
||||
selector.innerHTML = '<option value="">Select chat...</option>';
|
||||
const lastSeen = dmLastSeenTimestamps[conv.conversation_id] || 0;
|
||||
const isUnread = conv.last_message_timestamp > lastSeen;
|
||||
|
||||
// Track which names are already in conversations
|
||||
const conversationNames = new Set();
|
||||
// Find matching device contact
|
||||
const contact = pkPrefix
|
||||
? contactsList.find(c => c.public_key && c.public_key.startsWith(pkPrefix))
|
||||
: contactsList.find(c => c.name === conv.display_name);
|
||||
|
||||
// 1. Add existing conversations (with history)
|
||||
if (dmConversations.length > 0) {
|
||||
dmConversations.forEach(conv => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = conv.conversation_id;
|
||||
return {
|
||||
conversationId: conv.conversation_id,
|
||||
name: conv.display_name,
|
||||
isUnread,
|
||||
contact: contact || null,
|
||||
};
|
||||
});
|
||||
|
||||
// Show unread indicator
|
||||
const lastSeen = dmLastSeenTimestamps[conv.conversation_id] || 0;
|
||||
const isUnread = conv.last_message_timestamp > lastSeen;
|
||||
// Device contacts without existing conversations
|
||||
const contacts = contactsList.filter(c => {
|
||||
const prefix = (c.public_key_prefix || c.public_key?.substring(0, 12) || '');
|
||||
return !convPubkeyPrefixes.has(prefix);
|
||||
});
|
||||
|
||||
let label = displayName(conv.display_name);
|
||||
if (isUnread) {
|
||||
label = `* ${label}`;
|
||||
}
|
||||
window._dmDropdownItems = { conversations, contacts };
|
||||
renderDropdownItems('');
|
||||
|
||||
opt.textContent = label;
|
||||
selector.appendChild(opt);
|
||||
// Update search input if conversation is selected
|
||||
if (currentConversationId && currentRecipient) {
|
||||
const input = document.getElementById('dmContactSearchInput');
|
||||
if (input) input.value = displayName(currentRecipient);
|
||||
}
|
||||
}
|
||||
|
||||
// Track this name
|
||||
conversationNames.add(conv.display_name);
|
||||
/**
|
||||
* Render dropdown items filtered by search query.
|
||||
*/
|
||||
function renderDropdownItems(query) {
|
||||
const dropdown = document.getElementById('dmContactDropdown');
|
||||
if (!dropdown) return;
|
||||
dropdown.innerHTML = '';
|
||||
|
||||
const q = query.toLowerCase().trim();
|
||||
const { conversations = [], contacts = [] } = window._dmDropdownItems || {};
|
||||
|
||||
const filteredConvs = q
|
||||
? conversations.filter(item => (item.name || '').toLowerCase().includes(q))
|
||||
: conversations;
|
||||
|
||||
const filteredContacts = q
|
||||
? contacts.filter(c => (c.name || '').toLowerCase().includes(q))
|
||||
: contacts;
|
||||
|
||||
if (filteredConvs.length > 0) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'dm-dropdown-separator';
|
||||
sep.textContent = 'Recent conversations';
|
||||
dropdown.appendChild(sep);
|
||||
|
||||
filteredConvs.forEach(item => {
|
||||
dropdown.appendChild(createDropdownItem(
|
||||
item.name, item.conversationId, item.isUnread, item.contact));
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Add separator if we have both conversations and contacts
|
||||
if (dmConversations.length > 0 && contactsList.length > 0) {
|
||||
const separator = document.createElement('option');
|
||||
separator.disabled = true;
|
||||
separator.textContent = '--- Available contacts ---';
|
||||
selector.appendChild(separator);
|
||||
}
|
||||
if (filteredContacts.length > 0) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'dm-dropdown-separator';
|
||||
sep.textContent = 'Contacts';
|
||||
dropdown.appendChild(sep);
|
||||
|
||||
// 3. Add all contacts from device (skip those already in conversations)
|
||||
if (contactsList.length > 0) {
|
||||
contactsList.forEach(contactName => {
|
||||
// Skip if already in conversations
|
||||
if (conversationNames.has(contactName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const opt = document.createElement('option');
|
||||
// Create conversation_id as name_<contactName>
|
||||
opt.value = `name_${contactName}`;
|
||||
opt.textContent = contactName;
|
||||
selector.appendChild(opt);
|
||||
filteredContacts.forEach(contact => {
|
||||
const prefix = contact.public_key_prefix || contact.public_key?.substring(0, 12) || '';
|
||||
const convId = `pk_${prefix}`;
|
||||
dropdown.appendChild(createDropdownItem(
|
||||
contact.name, convId, false, contact));
|
||||
});
|
||||
}
|
||||
|
||||
// Show message if no conversations and no contacts
|
||||
if (dmConversations.length === 0 && contactsList.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = 'No contacts available';
|
||||
opt.disabled = true;
|
||||
selector.appendChild(opt);
|
||||
if (filteredConvs.length === 0 && filteredContacts.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'dm-dropdown-separator text-center';
|
||||
empty.textContent = q ? 'No matches' : 'No contacts available';
|
||||
dropdown.appendChild(empty);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single dropdown item element.
|
||||
*/
|
||||
function createDropdownItem(name, conversationId, isUnread, contact) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'dm-contact-item';
|
||||
|
||||
if (isUnread) {
|
||||
const dot = document.createElement('span');
|
||||
dot.style.cssText = 'color: #0d6efd; font-weight: bold;';
|
||||
dot.textContent = '*';
|
||||
el.appendChild(dot);
|
||||
}
|
||||
|
||||
// If we have a current conversation, select it
|
||||
if (currentConversationId) {
|
||||
selector.value = currentConversationId;
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'contact-name';
|
||||
nameSpan.textContent = displayName(name);
|
||||
el.appendChild(nameSpan);
|
||||
|
||||
if (contact && contact.type_label) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge';
|
||||
const colors = { CLI: 'bg-primary', REP: 'bg-success', ROOM: 'bg-info', SENS: 'bg-warning' };
|
||||
badge.classList.add(colors[contact.type_label] || 'bg-secondary');
|
||||
badge.textContent = contact.type_label;
|
||||
el.appendChild(badge);
|
||||
}
|
||||
|
||||
el.addEventListener('click', () => selectConversationFromDropdown(conversationId, name));
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle selection from the searchable dropdown.
|
||||
*/
|
||||
function selectConversationFromDropdown(conversationId, name) {
|
||||
const input = document.getElementById('dmContactSearchInput');
|
||||
const dropdown = document.getElementById('dmContactDropdown');
|
||||
if (input) input.value = displayName(name);
|
||||
if (dropdown) dropdown.style.display = 'none';
|
||||
selectConversation(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -368,8 +474,11 @@ async function selectConversation(conversationId) {
|
||||
if (conv && conv.display_name) {
|
||||
currentRecipient = conv.display_name;
|
||||
} else {
|
||||
// Extract name from conversation_id
|
||||
if (conversationId.startsWith('name_')) {
|
||||
// Try to find name from contactsList
|
||||
const contact = findCurrentContactByConvId(conversationId);
|
||||
if (contact && contact.name) {
|
||||
currentRecipient = contact.name;
|
||||
} else if (conversationId.startsWith('name_')) {
|
||||
currentRecipient = conversationId.substring(5);
|
||||
} else if (conversationId.startsWith('pk_')) {
|
||||
currentRecipient = conversationId.substring(3, 11) + '...';
|
||||
@@ -378,11 +487,13 @@ async function selectConversation(conversationId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update selector if not already selected
|
||||
const selector = document.getElementById('dmConversationSelector');
|
||||
if (selector && selector.value !== conversationId) {
|
||||
selector.value = conversationId;
|
||||
}
|
||||
// Update search input
|
||||
const searchInput = document.getElementById('dmContactSearchInput');
|
||||
if (searchInput) searchInput.value = displayName(currentRecipient);
|
||||
|
||||
// Enable info button
|
||||
const infoBtn = document.getElementById('dmContactInfoBtn');
|
||||
if (infoBtn) infoBtn.disabled = false;
|
||||
|
||||
// Enable input
|
||||
const input = document.getElementById('dmMessageInput');
|
||||
@@ -409,6 +520,12 @@ function clearConversation() {
|
||||
// Clear from localStorage
|
||||
localStorage.removeItem('mc_active_dm_conversation');
|
||||
|
||||
// Reset search input and info button
|
||||
const searchInput = document.getElementById('dmContactSearchInput');
|
||||
if (searchInput) searchInput.value = '';
|
||||
const infoBtn = document.getElementById('dmContactInfoBtn');
|
||||
if (infoBtn) infoBtn.disabled = true;
|
||||
|
||||
// Disable input
|
||||
const input = document.getElementById('dmMessageInput');
|
||||
const sendBtn = document.getElementById('dmSendBtn');
|
||||
@@ -436,6 +553,130 @@ function clearConversation() {
|
||||
updateCharCounter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find contact object matching a conversation ID.
|
||||
*/
|
||||
function findCurrentContactByConvId(convId) {
|
||||
if (!convId) return null;
|
||||
let pkPrefix = '';
|
||||
if (convId.startsWith('pk_')) {
|
||||
pkPrefix = convId.substring(3);
|
||||
}
|
||||
if (pkPrefix) {
|
||||
return contactsList.find(c => c.public_key && c.public_key.startsWith(pkPrefix)) || null;
|
||||
}
|
||||
// Fallback: match by name
|
||||
if (convId.startsWith('name_')) {
|
||||
const name = convId.substring(5);
|
||||
return contactsList.find(c => c.name === name) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find current contact from contactsList.
|
||||
*/
|
||||
function findCurrentContact() {
|
||||
return findCurrentContactByConvId(currentConversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal relative time formatter.
|
||||
*/
|
||||
function formatRelativeTimeDm(timestamp) {
|
||||
if (!timestamp) return 'Never';
|
||||
const diff = Math.floor(Date.now() / 1000) - timestamp;
|
||||
if (diff < 60) return 'Just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the Contact Info modal body.
|
||||
*/
|
||||
function populateContactInfoModal() {
|
||||
const body = document.getElementById('dmContactInfoBody');
|
||||
if (!body) return;
|
||||
|
||||
const contact = findCurrentContact();
|
||||
if (!contact) {
|
||||
body.innerHTML = '<p class="text-muted">No contact information available.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = '';
|
||||
|
||||
// Name + type badge
|
||||
const nameRow = document.createElement('div');
|
||||
nameRow.className = 'd-flex align-items-center gap-2 mb-3';
|
||||
const nameEl = document.createElement('h6');
|
||||
nameEl.className = 'mb-0';
|
||||
nameEl.textContent = contact.name;
|
||||
nameRow.appendChild(nameEl);
|
||||
|
||||
if (contact.type_label) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge';
|
||||
const colors = { CLI: 'bg-primary', REP: 'bg-success', ROOM: 'bg-info', SENS: 'bg-warning' };
|
||||
badge.classList.add(colors[contact.type_label] || 'bg-secondary');
|
||||
badge.textContent = contact.type_label;
|
||||
nameRow.appendChild(badge);
|
||||
}
|
||||
body.appendChild(nameRow);
|
||||
|
||||
// Public key
|
||||
const keyDiv = document.createElement('div');
|
||||
keyDiv.className = 'text-muted small font-monospace mb-2';
|
||||
keyDiv.style.cursor = 'pointer';
|
||||
keyDiv.textContent = contact.public_key_prefix || contact.public_key?.substring(0, 12) || '';
|
||||
keyDiv.title = 'Click to copy full public key';
|
||||
keyDiv.onclick = () => {
|
||||
const pk = contact.public_key || contact.public_key_prefix || '';
|
||||
navigator.clipboard.writeText(pk).then(() => {
|
||||
showNotification('Public key copied', 'info');
|
||||
}).catch(() => {});
|
||||
};
|
||||
body.appendChild(keyDiv);
|
||||
|
||||
// Last advert
|
||||
if (contact.last_seen || contact.last_advert) {
|
||||
const ts = contact.last_seen || contact.last_advert;
|
||||
const diff = Math.floor(Date.now() / 1000) - ts;
|
||||
let icon = '🔴';
|
||||
if (diff < 300) icon = '🟢';
|
||||
else if (diff < 3600) icon = '🟡';
|
||||
const div = document.createElement('div');
|
||||
div.className = 'small mb-2';
|
||||
div.textContent = `${icon} Last advert: ${formatRelativeTimeDm(ts)}`;
|
||||
body.appendChild(div);
|
||||
}
|
||||
|
||||
// Path/route
|
||||
if (contact.path_or_mode) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'small mb-2';
|
||||
const mode = contact.path_or_mode;
|
||||
if (mode === 'Flood') {
|
||||
div.innerHTML = '<i class="bi bi-broadcast"></i> Flood';
|
||||
} else if (mode === 'Direct') {
|
||||
div.innerHTML = '<i class="bi bi-arrow-right-short"></i> Direct';
|
||||
} else {
|
||||
const hops = mode.split('→').length;
|
||||
div.innerHTML = `<i class="bi bi-signpost-split"></i> ${mode} <span class="text-muted">(${hops} hops)</span>`;
|
||||
}
|
||||
body.appendChild(div);
|
||||
}
|
||||
|
||||
// GPS
|
||||
if (contact.adv_lat && contact.adv_lon && (contact.adv_lat !== 0 || contact.adv_lon !== 0)) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'small mb-2';
|
||||
div.innerHTML = `<i class="bi bi-geo-alt"></i> ${contact.adv_lat.toFixed(4)}, ${contact.adv_lon.toFixed(4)}`;
|
||||
body.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages for current conversation
|
||||
*/
|
||||
|
||||
@@ -57,6 +57,50 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Searchable contact dropdown */
|
||||
.dm-contact-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1050;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-top: none;
|
||||
border-radius: 0 0 0.375rem 0.375rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.dm-contact-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.dm-contact-item:hover,
|
||||
.dm-contact-item.active {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
.dm-contact-item .contact-name {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dm-contact-item .badge {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.dm-dropdown-separator {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -67,14 +111,23 @@
|
||||
<div class="row border-bottom bg-light">
|
||||
<div class="col-12 p-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select id="dmConversationSelector" class="form-select" title="Select conversation">
|
||||
<option value="">Select chat...</option>
|
||||
<!-- Conversations loaded dynamically via JavaScript -->
|
||||
</select>
|
||||
<div class="form-check form-switch flex-shrink-0" title="Auto Retry: resend DM if no ACK received">
|
||||
<input class="form-check-input" type="checkbox" id="dmAutoRetryToggle" checked>
|
||||
<label class="form-check-label small text-nowrap" for="dmAutoRetryToggle">Retry</label>
|
||||
<!-- Searchable contact selector -->
|
||||
<div class="position-relative flex-grow-1" id="dmContactSearchWrapper">
|
||||
<input type="text"
|
||||
id="dmContactSearchInput"
|
||||
class="form-control"
|
||||
placeholder="Select chat..."
|
||||
autocomplete="off">
|
||||
<div id="dmContactDropdown" class="dm-contact-dropdown" style="display: none;"></div>
|
||||
</div>
|
||||
<!-- Contact info button -->
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary flex-shrink-0"
|
||||
id="dmContactInfoBtn"
|
||||
title="Contact info"
|
||||
disabled>
|
||||
<i class="bi bi-info-circle"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,6 +219,28 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Contact Info Modal -->
|
||||
<div class="modal fade" id="dmContactInfoModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title"><i class="bi bi-person-circle"></i> Contact Info</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="dmContactInfoBody"></div>
|
||||
<div class="modal-footer">
|
||||
<div class="d-flex align-items-center justify-content-between w-100">
|
||||
<div class="form-check form-switch" title="Auto Retry: resend DM if no ACK received">
|
||||
<input class="form-check-input" type="checkbox" id="dmAutoRetryToggle" checked>
|
||||
<label class="form-check-label small" for="dmAutoRetryToggle">Auto Retry</label>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast container for notifications -->
|
||||
<div class="toast-container position-fixed top-0 start-0 p-3">
|
||||
<div id="notificationToast" class="toast" role="alert">
|
||||
|
||||
Reference in New Issue
Block a user