let currentPage = 1;
let currentSort = 'date_desc';
// Per-area page memory, keyed by area tag. Loaded from and saved to DB via web-mail-state API.
let echoPageMemory = {};
let currentMessageId = null;
let currentFilter = 'all';
let modalClosedByBackButton = false;
let threadedView = false;
let userSettings = {};
let currentMessages = [];
let currentMessageIndex = -1;
let currentSearchTerms = [];
let currentMessageData = null;
let currentParsedMessage = null;
let currentRenderMode = 'auto';
let keyboardHelpVisible = false;
let allEchoareas = [];
let echoareaSearchQuery = '';
let currentEchoareaData = null; // area object for the currently viewed echo
let allEchoareasCache = null; // lazy full-list cache for unsubscribed area lookups
let searchResultCounts = null;
let searchFilterCounts = null;
let originalFilterCounts = null;
let isSearchActive = false;
let currentPagination = null;
let _messageRiptermLoaderPromise = null;
let requestedMessageId = null;
let currentInterestId = null;
let currentInterestName = '';
let currentInterestSlug = '';
let areaListInterestFilter = null;
let loadedInterests = [];
let currentConversationMessageId = null;
let currentConversationSubject = '';
let currentContextMenuMessageId = null;
let currentContextMenuMessageSaved = false;
function apiError(payload, fallback) {
if (window.getApiErrorMessage) {
return window.getApiErrorMessage(payload, fallback);
}
if (payload && payload.error) {
return String(payload.error);
}
return fallback;
}
function uiT(key, fallback, params = {}) {
if (window.t) {
return window.t(key, params, fallback);
}
return fallback;
}
// Date display configuration: 'written' or 'received'
// Sourced from server-side ECHOMAIL_ORDER_DATE env configuration.
const USE_DATE_FIELD = (window.echomailDateField === 'written') ? 'written' : 'received';
$(document).ready(function() {
loadEchomailSettings().then(function() {
const urlParams = new URLSearchParams(window.location.search);
const searchQuery = urlParams.get('search');
const messageParam = urlParams.get('message');
requestedMessageId = messageParam && /^\d+$/.test(messageParam) ? parseInt(messageParam, 10) : null;
loadEchoareas();
if (searchQuery) {
// Populate search input and trigger search
$('#searchInput').val(searchQuery);
$('#mobileSearchInput').val(searchQuery);
searchMessages();
} else {
// Restore last visited page for the current area
const memKey = currentEchoarea || '__all__';
if (echoPageMemory[memKey]) {
currentPage = echoPageMemory[memKey];
}
loadMessages(function() {
openRequestedMessage();
});
}
});
// Initialize history state
if (!history.state) {
history.replaceState({}, '', '');
}
// Handle browser back button for modal
window.addEventListener('popstate', function(event) {
if ($('#messageModal').hasClass('show')) {
modalClosedByBackButton = true;
$('#messageModal').modal('hide');
}
});
// Handle modal close events
$('#messageModal').on('hidden.bs.modal', function() {
cleanupMessageModalMedia();
// If modal wasn't closed by back button and we're in a modal state, go back in history
if (!modalClosedByBackButton && history.state && history.state.modal === 'message') {
history.back();
}
modalClosedByBackButton = false;
hideKeyboardHelp();
});
$('#ignoreMessageForm').on('submit', saveIgnoreMessageRule);
// Add keyboard navigation for message modal
$(document).on('keydown', function(e) {
// Only handle keyboard shortcuts when the message modal is open
if ($('#messageModal').hasClass('show')) {
switch(e.key) {
case 'ArrowLeft':
e.preventDefault();
navigateMessage(-1);
break;
case 'ArrowRight':
e.preventDefault();
navigateMessage(1);
break;
case 'd':
case 'D':
e.preventDefault();
downloadCurrentMessage();
break;
case 'Escape':
// Let the default modal behavior handle this
break;
case 'f':
case 'F':
if (e.ctrlKey || e.metaKey) break;
e.preventDefault();
toggleModalFullscreen();
break;
case 'a':
case 'A':
e.preventDefault();
cycleRenderMode();
break;
case '?':
case 'h':
case 'H':
e.preventDefault();
toggleKeyboardHelp();
break;
}
}
});
// Add touch/swipe navigation for message modal
setupModalSwipeNavigation();
// Initialize mobile accordion text
updateMobileAccordionText(currentEchoarea);
// Search on enter key for both desktop and mobile
$('#searchInput, #mobileSearchInput').on('keypress', function(e) {
if (e.which === 13) {
searchMessages();
}
});
// Polling disabled ? BinkStream delivers dashboard_stats on new echomail.
// Re-enable by uncommenting if BinkStream is unavailable.
// startAutoRefresh(function() { loadMessages(); loadStats(); }, 300000);
// Refresh when the page becomes visible again (PWA restore, tab focus, browser un-minimize).
// BinkStream events are throttled while the page is hidden, so a manual refresh is needed.
let _hiddenAt = null;
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'hidden') {
_hiddenAt = Date.now();
} else if (document.visibilityState === 'visible' && _hiddenAt !== null) {
// Only refresh if the page was hidden for more than 30 seconds.
if (Date.now() - _hiddenAt >= 30000) {
if (!isSearchActive) {
loadMessages(null, true);
}
loadStats();
loadEchoareas();
}
_hiddenAt = null;
}
});
if (window.BinkStream) {
// Reload the message list when new echomail arrives.
// Debounced to absorb bursts from concurrent imports.
let _dashboardRefreshTimer = null;
window.BinkStream.on('dashboard_stats', function() {
clearTimeout(_dashboardRefreshTimer);
_dashboardRefreshTimer = setTimeout(function() {
if (!isSearchActive) {
loadMessages(null, true);
}
loadStats();
loadEchoareas();
}, 2000);
});
// Cross-tab read sync ? apply read styling immediately when another tab
// marks a message as read without requiring a full list reload.
window.BinkStream.on('message_read', function(data) {
if (data.message_type !== 'echomail') return;
(data.message_ids || []).forEach(function(id) {
applyEchomailReadStyle(id);
});
});
}
document.addEventListener('click', hideMessageContextMenu);
document.addEventListener('scroll', hideMessageContextMenu, true);
window.addEventListener('resize', hideMessageContextMenu);
bindMessageListTouchContextMenu();
});
function cleanupMessageModalMedia() {
const content = document.getElementById('messageContent');
if (!content) return;
if (window.BinkMediaPlayer && typeof window.BinkMediaPlayer.cleanup === 'function') {
window.BinkMediaPlayer.cleanup(content);
}
content.querySelectorAll('video, audio').forEach(function(mediaEl) {
try {
mediaEl.pause();
mediaEl.removeAttribute('src');
mediaEl.querySelectorAll('source').forEach(function(source) {
source.removeAttribute('src');
});
mediaEl.load();
} catch (e) {}
});
content.querySelectorAll('iframe, embed, object').forEach(function(embedEl) {
embedEl.remove();
});
}
function loadEchoareas(callback) {
$.get('/api/echoareas?subscribed_only=true')
.done(function(data) {
allEchoareas = data.echoareas;
applyEchoareaFilter();
updateEchoInfoBar();
if (typeof callback === 'function') callback();
})
.fail(function() {
$('#echoareasList').html(`<div class="text-center text-danger p-3">${uiT('ui.echoareas.load_failed', 'Failed to load echo areas')}</div>`);
$('#mobileEchoareasList').html(`<div class="text-center text-danger p-3">${uiT('ui.echoareas.load_failed', 'Failed to load echo areas')}</div>`);
if (typeof callback === 'function') callback();
});
}
/**
* Update the echo info bar (description + subscribe button) for the current echo.
* If no echo is selected the bar is hidden.
*/
function updateEchoInfoBar() {
if (!currentEchoarea) {
// No specific area selected ? show a generic "All Messages" bar with compose button
$('#echoTitle').text(uiT('ui.common.all_messages', 'All Messages'));
$('#echoDescription').text('');
$('#echoSubscribeBtn').addClass('d-none');
$('#echoInfoBar').removeClass('d-none');
currentEchoareaData = null;
return;
}
// Look for the area in the already-loaded subscribed list
currentEchoareaData = null;
if (allEchoareas) {
for (const area of allEchoareas) {
const fullTag = area.domain ? `${area.tag}@${area.domain}` : area.tag;
if (fullTag === currentEchoarea) {
currentEchoareaData = area;
break;
}
}
}
if (currentEchoareaData) {
renderEchoInfoBar(currentEchoareaData, true);
} else {
// Area not in subscribed list ? lazy-fetch all areas to get description + ID
if (allEchoareasCache) {
const found = allEchoareasCache[currentEchoarea] || null;
currentEchoareaData = found;
renderEchoInfoBar(found, false);
} else {
// Show bar immediately with spinner while fetching
$('#echoDescription').text('');
$('#echoSubscribeBtn').prop('disabled', true).text('...');
$('#echoInfoBar').removeClass('d-none');
$.get('/api/echoareas').done(function(data) {
allEchoareasCache = {};
(data.echoareas || []).forEach(function(area) {
const fullTag = area.domain ? `${area.tag}@${area.domain}` : area.tag;
allEchoareasCache[fullTag] = area;
});
const found = allEchoareasCache[currentEchoarea] || null;
currentEchoareaData = found;
renderEchoInfoBar(found, false);
}).fail(function() {
renderEchoInfoBar(null, false);
});
}
}
}
/**
* Render the info bar contents given an area object and subscription state.
* @param {object|null} area Area data (may be null for unknown areas)
* @param {boolean} subscribed
*/
function renderEchoInfoBar(area, subscribed) {
const title = area ? (area.domain ? `${area.tag}@${area.domain}` : (area.tag || '')) : '';
const description = area ? (area.description || '') : '';
const areaId = area ? area.id : null;
$('#echoTitle').text(title);
$('#echoDescription').text(description);
const btn = $('#echoSubscribeBtn');
btn.prop('disabled', false).attr('data-area-id', areaId || '').attr('data-subscribed', subscribed ? '1' : '0');
if (subscribed) {
btn.removeClass('btn-outline-success').addClass('btn-outline-secondary')
.html(`<i class="fas fa-star me-1"></i>${uiT('ui.echomail.unsubscribe', 'Unsubscribe')}`);
} else {
btn.removeClass('btn-outline-secondary').addClass('btn-outline-success')
.html(`<i class="far fa-star me-1"></i>${uiT('ui.echomail.subscribe', 'Subscribe')}`);
}
const editBtn = $('#echoEditAreaBtn');
if (editBtn.length) {
if (area && area.tag) {
const params = new URLSearchParams({ tag: String(area.tag) });
const areaDomain = String(area.domain || '').trim();
if (areaDomain) {
params.set('domain', areaDomain);
}
editBtn.attr('href', `/echoareas?${params.toString()}`).removeClass('d-none');
} else {
editBtn.attr('href', '/echoareas').addClass('d-none');
}
}
$('#echoInfoBar').removeClass('d-none');
}
/**
* Show the info bar for an interest view.
* Hides the subscribe button (not applicable here) and exposes the compose button.
* @param {string} name Interest name
*/
function renderInterestInfoBar(name) {
$('#echoTitle').text(name);
$('#echoDescription').text('');
$('#echoSubscribeBtn').addClass('d-none');
$('#echoEditAreaBtn').addClass('d-none');
$('#echoInfoBar').removeClass('d-none');
}
/**
* Toggle subscription for the currently viewed echo area.
*/
function toggleSubscription() {
const btn = $('#echoSubscribeBtn');
const areaId = parseInt(btn.attr('data-area-id'));
const subscribed = btn.attr('data-subscribed') === '1';
if (!areaId) return;
const action = subscribed ? 'unsubscribe' : 'subscribe';
btn.prop('disabled', true);
$.ajax({
url: '/api/subscriptions/user',
method: 'POST',
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify({ action: action, echoarea_id: areaId }),
success: function(data) {
if (data.success) {
// Update button immediately, then reload sidebar in background
renderEchoInfoBar(currentEchoareaData, !subscribed);
allEchoareasCache = null;
loadEchoareas();
} else {
btn.prop('disabled', false);
}
},
error: function() {
btn.prop('disabled', false);
}
});
}
function searchEchoareas(query) {
echoareaSearchQuery = query.toLowerCase();
applyEchoareaFilter();
}
function applyEchoareaFilter() {
let filtered = allEchoareas;
if (areaListInterestFilter !== null) {
filtered = filtered.filter(area =>
(area.interest_ids || []).includes(areaListInterestFilter)
);
}
if (echoareaSearchQuery.length > 0) {
filtered = filtered.filter(area =>
area.tag.toLowerCase().includes(echoareaSearchQuery) ||
(area.description && area.description.toLowerCase().includes(echoareaSearchQuery))
);
}
displayEchoareas(filtered);
displayMobileEchoareas(filtered);
}
/**
* Populate the interest filter dropdowns in the area list tab.
* Called once after interests are loaded from the API.
* @param {Array} interests
*/
function populateAreaListInterestDropdowns(interests) {
loadedInterests = interests;
const allOption = `<option value="">${uiT('ui.echomail.all_subscribed_areas', 'All Subscribed Areas')}</option>`;
let options = allOption;
interests.forEach(function(interest) {
options += `<option value="${interest.id}">${escapeHtml(interest.name)}</option>`;
});
$('#areaListInterestFilter, #mobileAreaListInterestFilter').html(options);
}
/**
* Set the interest filter on the area list tab, re-render the list,
* and snap to "All Messages" scoped to that interest.
* @param {number|null} id Interest ID, or null for all subscribed areas.
*/
function setAreaListInterestFilter(id) {
areaListInterestFilter = id;
$('#areaListInterestFilter, #mobileAreaListInterestFilter').val(id === null ? '' : String(id));
applyEchoareaFilter();
// Always snap to "All Messages" and reload messages scoped to the new filter
currentEchoarea = null;
_applyInterestFilterToAllMessages(id);
// Update active state in sidebar to highlight "All Messages"
$('.node-item, .list-group-item-action').removeClass('bg-primary text-white active');
$('.node-item .badge, .list-group-item-action .badge').removeClass('bg-light text-dark').addClass('bg-secondary');
$(".node-item[onclick*=\"selectEchoarea(null)\"]").addClass('bg-primary text-white');
$(".node-item[onclick*=\"selectEchoarea(null)\"] .badge").removeClass('bg-secondary').addClass('bg-light text-dark');
$(".list-group-item-action[onclick*=\"selectEchoarea(null)\"]").addClass('active');
$('#mobileInterestsList .list-group-item-action, #desktopInterestsList .list-group-item-action').removeClass('active');
if (id !== null) {
$(`#mobileInterestsList .list-group-item-action[data-interest-id="${id}"], #desktopInterestsList .list-group-item-action[data-interest-id="${id}"]`).addClass('active');
}
history.pushState({echoarea: null}, '', '/echomail');
updateEchoInfoBar();
loadMessages();
}
/**
* Apply an interest filter to the "All Messages" view by updating currentInterestId.
* @param {number|null} id
*/
function _applyInterestFilterToAllMessages(id) {
if (id !== null) {
const interest = loadedInterests.find(function(i) { return i.id === id; });
if (interest) {
currentInterestId = interest.id;
currentInterestName = interest.name;
currentInterestSlug = interest.slug || '';
return;
}
}
currentInterestId = null;
currentInterestName = '';
currentInterestSlug = '';
}
function displayEchoareas(echoareas) {
const container = $('#echoareasList');
let html = '';
if (echoareas && echoareas.length > 0) {
// All messages option
html += `
<div class="node-item ${!currentEchoarea ? 'bg-primary text-white' : ''}" onclick="selectEchoarea(null)">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="node-system">${uiT('ui.common.all_messages', 'All Messages')}</div>
<small class="text-muted">${uiT('ui.echomail.view_all_echo_areas', 'View all echo areas')}</small>
</div>
<span class="badge bg-secondary">All</span>
</div>
</div>
`;
echoareas.forEach(function(area) {
const areaDomain = (area.domain || '').toString().trim();
const fullTag = areaDomain ? `${area.tag}@${areaDomain}` : area.tag;
const isActive = currentEchoarea === fullTag;
const unreadCount = area.unread_count || 0;
const totalCount = area.message_count || 0;
// Use search count if search is active
let countDisplay;
if (isSearchActive && area.search_count !== undefined) {
countDisplay = `<span class="badge bg-info">${uiT('ui.echomail.search_found_count', '{count} found', { count: area.search_count })}</span>`;
} else {
countDisplay = `<span class="badge ${isActive ? 'bg-light text-dark' : 'bg-secondary'}">${unreadCount}/${totalCount}</span>`;
}
html += `
<div class="node-item ${isActive ? 'bg-primary text-white' : ''}" onclick="selectEchoarea('${fullTag}')">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="node-address">${area.tag} ${area.domain ? `<span class="badge ${isActive ? 'bg-light text-dark' : 'bg-secondary'}" style="font-size: 0.65em;">${area.domain}</span>` : ''}</div>
<div class="node-system">${escapeHtml(area.description)}</div>
</div>
${countDisplay}
</div>
</div>
`;
});
} else {
html = `<div class="text-center text-muted p-3">${uiT('ui.echomail.no_echoareas_available', 'No echo areas available')}</div>`;
}
container.html(html);
}
function displayMobileEchoareas(echoareas) {
const container = $('#mobileEchoareasList');
let html = '';
if (echoareas && echoareas.length > 0) {
// All messages option
html += `
<div class="list-group-item list-group-item-action ${!currentEchoarea ? 'active' : ''}" onclick="selectEchoarea(null)">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="fw-bold">${uiT('ui.common.all_messages', 'All Messages')}</div>
<small class="text-muted">${uiT('ui.echomail.view_all_echo_areas', 'View all echo areas')}</small>
</div>
<span class="badge bg-secondary">All</span>
</div>
</div>
`;
echoareas.forEach(function(area) {
const areaDomain = (area.domain || '').toString().trim();
const fullTag = areaDomain ? `${area.tag}@${areaDomain}` : area.tag;
const isActive = currentEchoarea === fullTag;
const unreadCount = area.unread_count || 0;
const totalCount = area.message_count || 0;
// Use search count if search is active
let countDisplay;
if (isSearchActive && area.search_count !== undefined) {
countDisplay = `<span class="badge bg-info">${uiT('ui.echomail.search_found_count', '{count} found', { count: area.search_count })}</span>`;
} else {
countDisplay = `<span class="badge ${isActive ? 'bg-light text-dark' : 'bg-secondary'}">${unreadCount}/${totalCount}</span>`;
}
html += `
<div class="list-group-item list-group-item-action ${isActive ? 'active' : ''}" onclick="selectEchoarea('${fullTag}')">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="fw-bold">${area.tag} ${area.domain ? `<span class="badge ${isActive ? 'bg-light text-dark' : 'bg-secondary'}" style="font-size: 0.65em;">${area.domain}</span>` : ''}</div>
<div class="text-muted small">${escapeHtml(area.description)}</div>
</div>
${countDisplay}
</div>
</div>
`;
});
// Wrap in list-group
html = '<div class="list-group list-group-flush">' + html + '</div>';
} else {
html = `<div class="text-center text-muted p-3">${uiT('ui.echomail.no_echoareas_available', 'No echo areas available')}</div>`;
}
container.html(html);
}
function displayMobileInterests(interests) {
let html = '';
if (interests && interests.length > 0) {
interests.forEach(function(interest) {
const isActive = currentInterestId === interest.id;
const icon = escapeHtml(interest.icon || 'fa-layer-group');
const color = escapeHtml(interest.color || '#6c757d');
const encodedName = encodeURIComponent(interest.name);
const iconStyle = isActive ? '' : ` style="color:${color}"`;
html += `
<div class="list-group-item list-group-item-action ${isActive ? 'active' : ''}"
data-interest-id="${interest.id}"
data-interest-name="${encodedName}"
data-interest-slug="${escapeHtml(interest.slug || '')}"
style="border-left: 3px solid ${color}; cursor: pointer;">
<div class="d-flex align-items-center gap-2">
<i class="fas ${icon} flex-shrink-0"${iconStyle}></i>
<span class="fw-bold">${escapeHtml(interest.name)}</span>
</div>
</div>
`;
});
html = '<div class="list-group list-group-flush">' + html + '</div>';
} else {
html = `<div class="text-center text-muted p-3">${uiT('ui.interests.no_interests', 'No interests available')}</div>`;
}
// Populate both mobile and desktop containers
['#mobileInterestsList', '#desktopInterestsList'].forEach(function(selector) {
const c = $(selector);
if (c.length === 0) return;
c.html(html);
c.off('click', '.list-group-item-action').on('click', '.list-group-item-action', function() {
const id = parseInt($(this).data('interest-id'), 10);
const name = decodeURIComponent($(this).data('interest-name') || '');
const slug = $(this).data('interest-slug') || '';
selectInterest(id, name, slug);
});
});
}
function selectInterest(id, name, slug) {
currentConversationMessageId = null;
currentConversationSubject = '';
currentInterestId = id;
currentInterestName = name;
currentInterestSlug = slug || '';
currentEchoarea = null;
currentPage = 1;
areaListInterestFilter = id;
// Keep the Area List tab's dropdown in sync with the currently selected interest
// without invoking the Area List tab's own message-loading flow.
$('#areaListInterestFilter, #mobileAreaListInterestFilter').val(String(id));
applyEchoareaFilter();
// Encode slug in URL so the interest is restored if the user navigates back
const urlSlug = currentInterestSlug || id;
history.pushState({ interestId: id }, '', '/echomail?interest=' + encodeURIComponent(urlSlug));
// Update mobile accordion text
updateMobileAccordionText(null, name);
// Collapse mobile accordion after selection
$('#echoAreasCollapse').collapse('hide');
// Update active state in interest lists (mobile + desktop) and clear area list selection
$('#mobileInterestsList .list-group-item-action, #desktopInterestsList .list-group-item-action').removeClass('active');
$(`#mobileInterestsList .list-group-item-action[data-interest-id="${id}"], #desktopInterestsList .list-group-item-action[data-interest-id="${id}"]`).addClass('active');
$('#mobileEchoareasList .list-group-item-action, #echoareasList .list-group-item-action').removeClass('active');
// Show info bar in interest mode: hide subscribe (not applicable), keep compose visible
renderInterestInfoBar(name);
loadMessages();
}
function selectEchoarea(tag) {
currentConversationMessageId = null;
currentConversationSubject = '';
// When "All Messages" is selected, scope to the active interest filter if one is set
if (!tag) {
_applyInterestFilterToAllMessages(areaListInterestFilter);
} else {
currentInterestId = null;
currentInterestName = '';
currentInterestSlug = '';
}
currentEchoarea = tag;
// Restore subscribe button visibility in case we're coming from interest mode
$('#echoSubscribeBtn').removeClass('d-none');
updateEchoInfoBar();
const memKey = tag || '__all__';
currentPage = echoPageMemory[memKey] ? echoPageMemory[memKey] : 1;
// Update URL without page reload
const url = tag ? `/echomail/${encodeURIComponent(tag)}` : '/echomail';
history.pushState({echoarea: tag}, '', url);
// Update mobile accordion text
updateMobileAccordionText(tag);
// Collapse mobile accordion after selection
$('#echoAreasCollapse').collapse('hide');
// Update active state in existing DOM instead of re-rendering entire list
$('.node-item, .list-group-item-action').removeClass('bg-primary text-white active');
$('.node-item .badge, .list-group-item-action .badge').removeClass('bg-light text-dark').addClass('bg-secondary');
// Add active state to the selected item
if (tag) {
// Find the clicked item by checking the onclick attribute
$(`.node-item[onclick*="selectEchoarea('${tag.replace(/'/g, "\\'")}')"]`).addClass('bg-primary text-white');
$(`.node-item[onclick*="selectEchoarea('${tag.replace(/'/g, "\\'")}')"] .badge`).removeClass('bg-secondary').addClass('bg-light text-dark');
$(`.list-group-item-action[onclick*="selectEchoarea('${tag.replace(/'/g, "\\'")}')"]`).addClass('active');
} else {
// "All Messages" is selected
$(".node-item[onclick*=\"selectEchoarea(null)\"]").addClass('bg-primary text-white');
$(".node-item[onclick*=\"selectEchoarea(null)\"] .badge").removeClass('bg-secondary').addClass('bg-light text-dark');
$(".list-group-item-action[onclick*=\"selectEchoarea(null)\"]").addClass('active');
}
loadMessages();
}
function loadMessages(callback, silent = false) {
if (!silent) showLoading('#messagesContainer');
// Clear search terms when loading regular messages (not from search)
currentSearchTerms = [];
if (currentFilter === 'drafts') {
// Load drafts instead of regular messages
loadDrafts();
return;
}
if (currentConversationMessageId) {
$.get(`/api/messages/echomail/message/${currentConversationMessageId}/conversation`)
.done(function(data) {
displayMessages(data.messages, true);
updatePagination(data.pagination);
updateUnreadCount(data.unreadCount || 0);
if (typeof callback === 'function') {
callback(data);
}
})
.fail(function() {
$('#messagesContainer').html(`<div class="text-center text-danger py-4">${uiT('errors.failed_load_messages', 'Failed to load messages')}</div>`);
});
return;
}
let url;
if (currentInterestId) {
url = `/api/interests/${currentInterestId}/messages`;
} else {
url = '/api/messages/echomail';
if (currentEchoarea) {
url += `/${encodeURIComponent(currentEchoarea)}`;
}
}
url += `?page=${currentPage}&sort=${currentSort}&filter=${currentFilter}`;
if (threadedView) {
url += '&threaded=true';
}
$.get(url)
.done(function(data) {
// If the saved page is beyond the last page, reset to page 1 and reload
if (currentPage > 1 && data.messages && data.messages.length === 0 && data.pagination && data.pagination.pages < currentPage) {
currentPage = 1;
loadMessages(callback);
return;
}
displayMessages(data.messages, data.threaded || false);
updatePagination(data.pagination);
updateUnreadCount(data.unreadCount || 0);
// Remember the current page for this area (null = All Messages, stored as '__all__')
echoPageMemory[currentEchoarea || '__all__'] = currentPage;
saveEchoPositions();
// Refresh stats to get updated filter counts
loadStats();
if (typeof callback === 'function') {
callback(data);
}
})
.fail(function() {
$('#messagesContainer').html(`<div class="text-center text-danger py-4">${uiT('errors.failed_load_messages', 'Failed to load messages')}</div>`);
});
}
function loadDrafts() {
$.get('/api/messages/drafts?type=echomail')
.done(function(data) {
if (data.success) {
displayDrafts(data.drafts);
// Clear pagination for drafts
$('#pagination').empty();
} else {
$('#messagesContainer').html(`<div class="text-center text-danger py-4">${uiT('errors.messages.drafts.list_failed', 'Failed to load drafts')}</div>`);
}
})
.fail(function() {
$('#messagesContainer').html(`<div class="text-center text-danger py-4">${uiT('errors.messages.drafts.list_failed', 'Failed to load drafts')}</div>`);
});
}
function displayDrafts(drafts) {
const container = $('#messagesContainer');
let html = '';
if (drafts.length === 0) {
html = `<div class="text-center text-muted py-4">${uiT('ui.echomail.no_drafts_found', 'No drafts found')}</div>`;
} else {
// Create table structure
html = `
<div class="table-responsive">
<table class="table table-hover message-table mb-0">
<thead>
<tr>
<th style="width: 25%">${uiT('ui.echomail.to_echo_area', 'To / Echo Area')}</th>
<th style="width: 50%">${uiT('ui.common.subject_label_short', 'Subject')}</th>
<th colspan="2" style="width: 25%">${uiT('ui.echomail.last_updated', 'Last Updated')}</th>
</tr>
</thead>
<tbody>
`;
drafts.forEach(function(draft) {
const displayTarget = draft.to_name || uiT('ui.common.all', 'All');
const displayArea = draft.echoarea || uiT('ui.echomail.no_area', 'No area');
html += `
<tr class="message-row" style="cursor: pointer;" onclick="continueDraft(${draft.id})">
<td>
<div><strong>${uiT('ui.common.to_label', 'To:')}</strong> ${escapeHtml(displayTarget)}</div>
<div class="text-muted small"><strong>${uiT('ui.common.area_label', 'Area:')}</strong> ${escapeHtml(displayArea)}</div>
</td>
<td>
<strong>${escapeHtml(draft.subject || uiT('messages.no_subject', '(No Subject)'))}</strong>
${draft.message_text ? `<br><small class="text-muted">${escapeHtml(draft.message_text.substring(0, 100))}${draft.message_text.length > 100 ? '...' : ''}</small>` : ''}
</td>
<td>
<div>${formatFullDate(draft.updated_at)}</div>
<div class="text-muted small">
<button class="btn btn-sm btn-outline-danger" onclick="event.stopPropagation(); deleteDraftConfirm(${draft.id})" title="${uiT('ui.common.delete_draft', 'Delete draft')}">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
`;
}
container.html(html);
}
function displayMessages(messages, isThreaded = false) {
const container = $('#messagesContainer');
let html = '';
// Store current messages for navigation
currentMessages = messages;
if (messages.length === 0) {
html = `<div class="text-center text-muted py-4">${uiT('messages.none_found', 'No messages found')}</div>`;
} else {
if (currentConversationMessageId) {
html += `
<div class="alert alert-info d-flex justify-content-between align-items-center mb-3">
<div>
<strong>${uiT('ui.common.showing_entire_conversation', 'Showing Entire Conversation')}</strong>
${currentConversationSubject ? `<div class="small mt-1">${escapeHtml(currentConversationSubject)}</div>` : ''}
</div>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="clearConversationView()">${uiT('ui.common.back_to_message_list', 'Back to Message List')}</button>
</div>
`;
}
// Create table structure
html += `
<div class="table-responsive">
<table class="table table-hover message-table mb-0">
<thead>
<tr>
<th style="width: 5%" id="selectAllColumn" class="d-none">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="selectAllMessages" onchange="toggleSelectAll()">
</div>
</th>
<th style="width: 25%">${uiT('ui.common.from', 'From')}</th>
<th style="width: 60%">${uiT('ui.common.subject_label_short', 'Subject')}</th>
<th colspan="2" style="width: 15%">${USE_DATE_FIELD === 'written' ? uiT('ui.echomail.written', 'Written') : uiT('ui.netmail.received', 'Received')}</th>
</tr>
</thead>
<tbody>
`;
messages.forEach(function(msg) {
// File item (from interest file areas)
if (msg.type === 'file') {
const sizeStr = msg.filesize ? formatFileSize(msg.filesize) : '';
const dateStr = formatDate(msg.date_received);
html += `
<tr class="message-row interest-file-row" data-file-id="${msg.id}" onclick="viewFile(${msg.id})" style="cursor:pointer;">
<td class="message-checkbox d-none"></td>
<td class="message-from">
<i class="fas fa-file-archive text-secondary me-1"></i>
<span class="badge bg-secondary" style="font-size:0.75em;">${escapeHtml(msg.file_area_tag || '')}</span>
${msg.uploader_name ? `<br><small class="text-muted">${escapeHtml(msg.uploader_name)}</small>` : ''}
</td>
<td class="message-subject">
<strong>${escapeHtml(msg.filename || '')}</strong>
${msg.short_description ? `<br><small class="text-muted">${escapeHtml(msg.short_description)}</small>` : ''}
${sizeStr ? `<small class="text-muted ms-2">(${escapeHtml(sizeStr)})</small>` : ''}
</td>
<td class="message-date" title="${escapeHtml(msg.date_received || '')}">${dateStr}</td>
<td></td>
</tr>
`;
return; // skip message rendering for file items
}
// Check if message is addressed to current user
const isToCurrentUser = msg.to_name && window.currentUserRealName && msg.to_name === window.currentUserRealName;
const toInfo = msg.to_name && msg.to_name !== uiT('ui.common.all', 'All') ?
` (${uiT('ui.echomail.to_prefix', 'to:')} <span class="${isToCurrentUser ? 'text-green fw-bold' : ''}">${escapeHtml(msg.to_name)}</span>)` : '';
const isRead = msg.is_read == 1;
const isShared = msg.is_shared == 1;
const isSaved = msg.is_saved == 1;
const readClass = isRead ? 'read' : 'unread';
const readIcon = isRead ? `<i class="fas fa-envelope-open text-muted me-1" title="${uiT('ui.common.read', 'Read')}"></i>` : `<i class="fas fa-envelope text-primary me-1" title="${uiT('ui.common.unread', 'Unread')}"></i>`;
const shareIcon = isShared ? `<i class="fas fa-share-alt text-success me-1" title="${uiT('ui.common.shared', 'Shared')}"></i>` : '';
const saveIcon = `<i class="fas fa-bookmark ${isSaved ? 'text-warning' : 'text-muted'} me-1 save-btn"
data-message-id="${msg.id}"
data-message-type="echomail"
data-saved="${isSaved}"
title="${isSaved ? uiT('ui.common.remove_from_saved', 'Remove from saved') : uiT('ui.common.save_for_later', 'Save for later')}"
style="cursor: pointer;"
onclick="toggleSaveMessage(${msg.id}, 'echomail', ${isSaved})"></i>`;
// Threading support
const threadLevel = msg.thread_level || 0;
const replyCount = msg.reply_count || 0;
const isThreadRoot = msg.is_thread_root || false;
const threadIcon = (threadLevel > 0 || replyCount > 0)
? `<i class="fas fa-reply me-1 text-muted" title="${uiT('ui.common.show_entire_conversation', 'Show Entire Conversation')}" style="cursor: pointer;" onclick="event.stopPropagation(); showConversation(${msg.id})"></i>`
: '';
const replyCountBadge = isThreadRoot && replyCount > 0 ? ` <span class="badge bg-secondary ms-1" title="${uiT('ui.common.replies_with_count', '{count} replies', { count: replyCount })}">${replyCount}</span>` : '';
// Add thread-specific CSS classes; indent up to 2 levels (0.5rem each)
const threadClasses = isThreaded ? `thread-level-${Math.min(threadLevel, 9)} ${isThreadRoot ? 'thread-root' : 'thread-reply'}` : '';
const indentRem = isThreaded && threadLevel > 0 ? Math.min(threadLevel, 2) * 0.75 : 0;
const threadIndent = indentRem > 0 ? `padding-left: ${indentRem}rem;` : '';
html += `
<tr class="message-row ${readClass} ${threadClasses}" data-message-id="${msg.id}" oncontextmenu="openMessageContextMenu(event, ${msg.id})">
<td class="message-checkbox d-none">
<div class="form-check">
<input class="form-check-input message-select" type="checkbox" value="${msg.id}" onchange="updateSelection()">
</div>
</td>
<td class="message-from clickable-cell" onclick="viewMessage(${msg.id})" style="cursor: pointer;${threadIndent}">
${threadIcon}${readIcon}${shareIcon}${saveIcon}<span class="echo-from-popover" style="cursor:pointer;" onclick="event.stopPropagation(); handleEchoFromClick(this)" data-from-name="${escapeHtml(msg.from_name)}" data-from-address="${escapeHtml(msg.from_address || '')}" data-to-address="${escapeHtml((msg.replyto_address && msg.replyto_address !== '') ? msg.replyto_address : (msg.from_address || ''))}" data-to-name="${escapeHtml((msg.replyto_name && msg.replyto_name !== '') ? msg.replyto_name : msg.from_name)}" data-subject="${escapeHtml('Re: ' + (msg.subject || ''))}">${escapeHtml(msg.from_name)}</span>
</td>
<td class="message-subject clickable-cell" onclick="viewMessage(${msg.id})" style="cursor: pointer;">
${!currentEchoarea ? `<div class="mb-1">
<span class="badge" style="background-color: ${msg.echoarea_color || '#28a745'}; color: white;">${msg.echoarea}</span>
${msg.echoarea_domain ? `<span class="badge bg-secondary ms-1" style="font-size: 0.7em;">${msg.echoarea_domain}</span>` : ''}
</div>` : ''}
${isRead ? '' : '<strong>'}${escapeHtml(msg.subject || uiT('messages.no_subject', '(No Subject)'))}${isRead ? '' : '</strong>'}${replyCountBadge}
${toInfo ? `<br><small class="text-muted">${toInfo}</small>` : ''}
</td>
<td class="message-date clickable-cell" onclick="viewMessage(${msg.id})" style="cursor: pointer;" title="${uiT('ui.common.written_prefix', 'Written:')} ${formatFullDate(msg.date_written)} ${uiT('ui.common.received_prefix', 'Received:')} ${formatFullDate(msg.date_received)}">${formatDate(USE_DATE_FIELD === 'written' ? msg.date_written : msg.date_received)}</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
`;
}
container.html(html);
// If select mode was active before the re-render, restore it
if (selectMode) {
$('#selectAllColumn').removeClass('d-none');
$('.message-checkbox').removeClass('d-none');
// Re-check any previously selected messages that are still in the new list
if (selectedMessages.size > 0) {
$('.message-select').each(function() {
if (selectedMessages.has(parseInt($(this).val()))) {
$(this).prop('checked', true);
}
});
updateSelection();
}
}
}
function updatePagination(pagination) {
currentPagination = pagination;
const container = $('#pagination');
let html = '';
if (currentConversationMessageId) {
container.empty();
return;
}
if (pagination.pages > 1) {
html = '<ul class="pagination pagination-sm mb-0">';
// Previous button
if (pagination.page > 1) {
html += `<li class="page-item"><a class="page-link" href="#" onclick="changePage(${pagination.page - 1})">${uiT('ui.common.previous', 'Previous')}</a></li>`;
}
// Page numbers: first, ellipsis, window around current, ellipsis, last
const cur = pagination.page, total = pagination.pages;
const pageBtn = (n) => {
const active = n === cur ? 'active' : '';
return `<li class="page-item ${active}"><a class="page-link" href="#" onclick="changePage(${n})">${n}</a></li>`;
};
const ellipsis = `<li class="page-item disabled"><span class="page-link">…</span></li>`;
const pages = new Set([1, total]);
for (let i = Math.max(1, cur - 2); i <= Math.min(total, cur + 2); i++) pages.add(i);
let prev = 0;
[...pages].sort((a, b) => a - b).forEach(p => {
if (prev && p - prev > 1) html += ellipsis;
html += pageBtn(p);
prev = p;
});
// Next button
if (pagination.page < pagination.pages) {
html += `<li class="page-item"><a class="page-link" href="#" onclick="changePage(${pagination.page + 1})">${uiT('ui.common.next', 'Next')}</a></li>`;
}
html += '</ul>';
}
container.html(html);
}
function changePage(page) {
currentPage = page;
loadMessages();
}
function showConversation(messageId) {
currentConversationMessageId = messageId;
const selected = getCurrentMessageById(messageId);
currentConversationSubject = selected && selected.subject ? selected.subject : '';
loadMessages();
}
function clearConversationView() {
currentConversationMessageId = null;
currentConversationSubject = '';
loadMessages();
}
function getCurrentMessageById(messageId) {
return currentMessages.find(msg => Number(msg.id) === Number(messageId)) || null;
}
function openMessageContextMenu(event, messageId) {
if (event.shiftKey) {
return;
}
event.preventDefault();
event.stopPropagation();
openMessageContextMenuAtPosition(event.pageX, event.pageY, messageId);
}
function openMessageContextMenuAtPosition(pageX, pageY, messageId) {
const menu = document.getElementById('messageContextMenu');
if (!menu) {
return;
}
currentContextMenuMessageId = messageId;
const selected = currentMessages.find(msg => Number(msg.id) === Number(messageId));
currentContextMenuMessageSaved = !!(selected && Number(selected.is_saved) === 1);
const saveLabel = document.getElementById('messageContextMenuSaveLabel');
if (saveLabel) {
saveLabel.textContent = currentContextMenuMessageSaved
? uiT('ui.common.remove_from_saved', 'Remove from saved')
: uiT('ui.common.save_for_later', 'Save for later');
}
menu.style.left = `${pageX}px`;
menu.style.top = `${pageY}px`;
menu.style.display = 'block';
}
function hideMessageContextMenu() {
const menu = document.getElementById('messageContextMenu');
if (!menu) {
return;
}
menu.style.display = 'none';
currentContextMenuMessageId = null;
currentContextMenuMessageSaved = false;
}
function bindMessageListTouchContextMenu() {
if (!window.MessageListContextMenu) {
return;
}
window.MessageListContextMenu.bindLongPress({
containerId: 'messagesContainer',
rowSelector: '.message-row[data-message-id]',
isInteractiveTarget: isMessageRowInteractiveTarget,
onLongPress: function(details) {
openMessageContextMenuAtPosition(details.pageX, details.pageY, details.messageId);
}
});
}
function isMessageRowInteractiveTarget(target) {
return !!target.closest(
'button, a, input, select, textarea, label, .save-btn, .echo-from-popover, .node-addr-popover, .message-select'
);
}
function replyFromContextMenu() {
if (!currentContextMenuMessageId) {
return;
}
const messageId = currentContextMenuMessageId;
hideMessageContextMenu();
composeMessage('echomail', messageId);
}
function repostFromContextMenu() {
if (!currentContextMenuMessageId) {
return;
}
const messageId = currentContextMenuMessageId;
hideMessageContextMenu();
repostMessage(messageId);
}
function forwardByNetmailFromContextMenu() {
if (!currentContextMenuMessageId) {
return;
}
const messageId = currentContextMenuMessageId;
hideMessageContextMenu();
forwardMessageByNetmail(messageId);
}
function viewConversationFromContextMenu() {
if (!currentContextMenuMessageId) {
return;
}
const messageId = currentContextMenuMessageId;
hideMessageContextMenu();
showConversation(messageId);
}
function toggleSaveFromContextMenu() {
if (!currentContextMenuMessageId) {
return;
}
const messageId = currentContextMenuMessageId;
const isSaved = currentContextMenuMessageSaved;
hideMessageContextMenu();
toggleSaveMessage(messageId, 'echomail', isSaved);
}
function downloadMessageFromContextMenu() {
if (!currentContextMenuMessageId) {
return;
}
const messageId = currentContextMenuMessageId;
hideMessageContextMenu();
window.location.href = `/api/messages/echomail/${messageId}/download`;
}
function openIgnoreMessageModalFromContextMenu() {
if (!currentContextMenuMessageId) {
return;
}
const messageId = currentContextMenuMessageId;
const message = getCurrentMessageById(messageId);
hideMessageContextMenu();
if (!message || message.type === 'file') {
showError(uiT('errors.messages.echomail.ignore.message_not_available', 'Unable to load the selected message'));
return;
}
const senderName = message.from_name || '';
const senderAddress = message.from_address || '';
const senderDisplay = senderAddress ? `${senderName} <${senderAddress}>` : senderName;
$('#ignoreMessageSender').val(senderName);
$('#ignoreMessageSenderAddress').val(senderAddress);
$('#ignoreMessageSenderDisplay').val(senderDisplay);
$('#ignoreMessageSubjectContains').val(message.subject || '');
$('#ignoreMessageModal').data('message-id', messageId).modal('show');
}
function saveIgnoreMessageRule(event) {
event.preventDefault();
const senderName = String($('#ignoreMessageSender').val() || '').trim();
const senderAddress = String($('#ignoreMessageSenderAddress').val() || '').trim();
const subjectContains = String($('#ignoreMessageSubjectContains').val() || '').trim();
const submitButton = $('#ignoreMessageSubmitButton');
if (!senderName) {
showError(uiT('errors.messages.echomail.ignore.invalid_input', 'Sender name is required'));
return;
}
submitButton.prop('disabled', true).html(`<i class="fas fa-spinner fa-spin me-1"></i>${uiT('ui.common.saving', 'Saving...')}`);
$.ajax({
url: '/api/messages/echomail/ignore-rules',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({
sender_name: senderName,
sender_address: senderAddress,
subject_contains: subjectContains
}),
success: function(response) {
submitButton.prop('disabled', false).html(`<i class="fas fa-eye-slash me-1"></i>${uiT('ui.echomail.ignore.save_button', 'Ignore message')}`);
if (!response || !response.success) {
showError(apiError(response, uiT('errors.messages.echomail.ignore.save_failed', 'Failed to save ignore rule')));
return;
}
$('#ignoreMessageModal').modal('hide');
showSuccess(
window.getApiMessage
? window.getApiMessage(response, uiT('ui.echomail.ignore.saved', 'Messages from {sender} with matching subject text will now be hidden', { sender: senderName }))
: uiT('ui.echomail.ignore.saved', 'Messages from {sender} with matching subject text will now be hidden', { sender: senderName })
);
loadMessages();
},
error: function(xhr) {
submitButton.prop('disabled', false).html(`<i class="fas fa-eye-slash me-1"></i>${uiT('ui.echomail.ignore.save_button', 'Ignore message')}`);
showError(apiError(xhr.responseJSON || {}, uiT('errors.messages.echomail.ignore.save_failed', 'Failed to save ignore rule')));
}
});
}
function shareMessageFromContextMenu() {
if (!currentContextMenuMessageId) {
return;
}
const messageId = currentContextMenuMessageId;
hideMessageContextMenu();
showShareDialog(messageId);
}
function forwardMessageToEmailFromContextMenu() {
if (!currentContextMenuMessageId) {
return;
}
const messageId = currentContextMenuMessageId;
hideMessageContextMenu();
$.post(`/api/messages/echomail/${messageId}/forward-email`)
.done(function(response) {
if (response && response.success) {
showForwardEmailFeedback(
true,
(window.getApiMessage
? window.getApiMessage(response, uiT('ui.common.forwarded_to_email', 'Message forwarded to your email'))
: uiT('ui.common.forwarded_to_email', 'Message forwarded to your email'))
);
} else {
showForwardEmailFeedback(
false,
apiError(response, uiT('errors.messages.forward_email.failed', 'Failed to forward message by email'))
);
}
})
.fail(function(xhr) {
showForwardEmailFeedback(
false,
apiError(xhr.responseJSON || {}, uiT('errors.messages.forward_email.failed', 'Failed to forward message by email'))
);
});
}
function showForwardEmailFeedback(success, message) {
if (window.MessageListContextMenu) {
window.MessageListContextMenu.showActionNotice(success, message);
return;
}
window.alert(message);
}
function sortMessages(sortBy) {
currentSort = sortBy;
currentPage = 1;
updateSortIndicator();
// Save sort preference
window.saveUserSetting('default_sort', sortBy);
loadMessages();
}
function updateSortIndicator() {
$('.sort-option').each(function() {
const $el = $(this);
$el.find('.sort-active-indicator').remove();
if ($el.data('sort') === currentSort) {
$el.prepend('<i class="fas fa-arrow-right me-2 sort-active-indicator text-primary" style="font-size:0.75em;"></i>');
}
});
}
function refreshMessages() {
loadMessages();
showSuccess(uiT('ui.echomail.messages_refreshed', 'Messages refreshed'));
}
function toggleThreading() {
threadedView = !threadedView;
currentPage = 1; // Reset to first page when toggling
// Update toggle text
const toggleText = $('#threadingToggleText');
if (threadedView) {
toggleText.text(uiT('ui.common.threading.show_flat', 'Show Flat'));
} else {
toggleText.text(uiT('ui.common.threading.show_threaded', 'Show Threaded'));
}
// Save preference
window.saveUserSetting('threaded_view', threadedView);
loadMessages();
}
function setFilter(filter) {
currentFilter = filter;
currentPage = 1;
updateFilterTabs();
loadMessages();
}
function updateFilterTabs() {
// Remove active class from all tabs
$('.nav-tabs .nav-link').removeClass('active');
// Add active class to current tab
if (currentFilter === 'all') {
$('#allTab').addClass('active');
} else if (currentFilter === 'unread') {
$('#unreadTab').addClass('active');
} else if (currentFilter === 'read') {
$('#readTab').addClass('active');
} else if (currentFilter === 'tome') {
$('#toMeTab').addClass('active');
} else if (currentFilter === 'saved') {
$('#savedTab').addClass('active');
} else if (currentFilter === 'drafts') {
$('#draftsTab').addClass('active');
}
}
function updateUnreadCount(count) {
$('#unreadCount').text(count);
}
function updateFilterCounts(counts) {
$('#allCount').text(counts.all || 0);
$('#unreadCount').text(counts.unread || 0);
$('#readCount').text(counts.read || 0);
$('#toMeCount').text(counts.tome || 0);
$('#savedCount').text(counts.saved || 0);
$('#draftsCount').text(counts.drafts || 0);
}
function applyEchomailReadStyle(messageId) {
const messageRow = $(`.message-row[data-message-id="${messageId}"]`);
if (!messageRow.length) return;
messageRow.removeClass('unread').addClass('read');
messageRow.find('.fa-envelope').removeClass('fas fa-envelope text-primary').addClass('fas fa-envelope-open text-muted');
messageRow.find('.message-subject strong').contents().unwrap();
messageRow.find('.fa-envelope-open').attr('title', 'Read');
messageRow.css('opacity', '0.85');
}
function markEchomailAsRead(messageId) {
$.post(`/api/messages/echomail/${messageId}/read`)
.done(function() {
applyEchomailReadStyle(messageId);
})
.fail(function() {
console.log('Failed to mark echomail as read');
});
}
function toggleModalFullscreen() {
const modal = document.getElementById('messageModal');
const dialog = modal.querySelector('.modal-dialog');
const icon = document.querySelector('#fullscreenToggle i');
if (dialog.classList.contains('modal-fullscreen')) {
dialog.classList.remove('modal-fullscreen');
dialog.classList.add('modal-lg');
icon.classList.remove('fa-compress');
icon.classList.add('fa-expand');
UserStorage.setItem('modalFullscreen', 'false');
} else {
dialog.classList.remove('modal-lg');
dialog.classList.add('modal-fullscreen');
icon.classList.remove('fa-expand');
icon.classList.add('fa-compress');
UserStorage.setItem('modalFullscreen', 'true');
}
}
function applyModalFullscreenPreference() {
const isFullscreen = UserStorage.getItem('modalFullscreen') === 'true';
const modal = document.getElementById('messageModal');
const dialog = modal.querySelector('.modal-dialog');
const icon = document.querySelector('#fullscreenToggle i');
if (isFullscreen) {
dialog.classList.remove('modal-lg');
dialog.classList.add('modal-fullscreen');
icon.classList.remove('fa-expand');
icon.classList.add('fa-compress');
}
}
function viewMessage(messageId) {
currentMessageId = messageId;
// Find the current message index in the messages array (exclude file items)
currentMessageIndex = currentMessages.findIndex(msg => msg.id == messageId && msg.type !== 'file');
// Update navigation button states
updateNavigationButtons();
// Add history entry for mobile back button support
if (!history.state || history.state.modal !== 'message') {
history.pushState({modal: 'message', messageId: messageId}, '', '');
}
// Mark as read immediately
markEchomailAsRead(messageId);
$('#messageContent').html(`
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin me-2"></i>
${uiT('ui.common.loading_message', 'Loading message...')}
</div>
`);
applyModalFullscreenPreference();
$('#messageModal').modal('show');
// Choose the appropriate API endpoint based on whether we have a specific echoarea
let apiUrl;
if (currentEchoarea) {
apiUrl = `/api/messages/echomail/${encodeURIComponent(currentEchoarea)}/${messageId}`;
} else {
apiUrl = `/api/messages/echomail/message/${messageId}`;
}
$.get(apiUrl)
.done(function(data) {
displayMessageContent(data);
// Auto-scroll to top of modal content
$('#messageModal .modal-body').scrollTop(0);
})
.fail(function() {
$('#messageContent').html(`<div class="text-danger">${uiT('errors.messages.echomail.get_failed', 'Failed to load message')}</div>`);
});
}
function displayMessageContent(message) {
// Re-enable toolbar buttons that may have been disabled by the end-of-echo prompt
$('#editMessageButton, #toggleHeaders, #modalAiAssistantButton, #printMessageButton').prop('disabled', false);
updateModalTitle(message.subject);
// Parse message to separate kludge lines from body
const parsedMessage = parseEchomailMessage(message.message_text || '', message.kludge_lines || '', message.bottom_kludges || null);
currentMessageData = message;
// Check if sender is already in address book before rendering
checkAndDisplayEchomailMessage(message, parsedMessage);
}
function toggleKeyboardHelp() {
keyboardHelpVisible = !keyboardHelpVisible;
$('#keyboardHelpOverlay').toggle(keyboardHelpVisible);
}
function hideKeyboardHelp() {
keyboardHelpVisible = false;
$('#keyboardHelpOverlay').hide();
}
function getNextRenderMode(mode) {
if (window.getNextViewerRenderMode) {
return window.getNextViewerRenderMode(mode);
}
const modes = ['auto', 'rip', 'ansi', 'amiga_ansi', 'plain', 'raw'];
const currentIndex = modes.indexOf(mode);
return modes[(currentIndex + 1 + modes.length) % modes.length];
}
function loadRiptermJsForMessages() {
if (_messageRiptermLoaderPromise) return _messageRiptermLoaderPromise;
if (window.RIPterm && window.BGI) {
_messageRiptermLoaderPromise = Promise.resolve();
return _messageRiptermLoaderPromise;
}
function loadScript(src) {
return new Promise((resolve, reject) => {
const existing = document.querySelector(`script[data-ripterm-src="${src}"]`);
if (existing) {
if (existing.dataset.loaded === 'true') {
resolve();
return;
}
existing.addEventListener('load', resolve, { once: true });
existing.addEventListener('error', reject, { once: true });
return;
}
const script = document.createElement('script');
script.src = src;
script.async = false;
script.dataset.riptermSrc = src;
script.addEventListener('load', () => {
script.dataset.loaded = 'true';
resolve();
}, { once: true });
script.addEventListener('error', reject, { once: true });
document.head.appendChild(script);
});
}
_messageRiptermLoaderPromise = loadScript('/vendor/riptermjs/BGI.js')
.then(() => loadScript('/vendor/riptermjs/ripterm.js'));
return _messageRiptermLoaderPromise;
}
function renderRipMessageBody(container, ripText) {
const canvasId = `messageRipCanvas_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
container.innerHTML = `
<div class="text-center py-4 text-muted" data-rip-loading>
<i class="fas fa-spinner fa-spin fa-2x"></i>
</div>
<div class="d-none" data-rip-stage style="overflow:auto;max-height:70vh;padding:8px;text-align:center;background:#0a0a0a;border-radius:6px;">
<canvas id="${canvasId}" width="640" height="350"
style="width:100%;max-width:960px;height:auto;image-rendering:pixelated;background:#000;border:1px solid #193247;border-radius:6px;"></canvas>
</div>
`;
loadRiptermJsForMessages()
.then(async () => {
const blobUrl = URL.createObjectURL(new Blob([ripText], { type: 'text/plain' }));
const ripterm = new window.RIPterm({
canvasId: canvasId,
timeInterval: 0,
refreshInterval: 25,
fontsPath: '/vendor/riptermjs/fonts',
iconsPath: '/vendor/riptermjs/icons',
logQuiet: true
});
await ripterm.initFonts();
ripterm.reset();
try {
await ripterm.openURL(blobUrl);
await ripterm.play();
} finally {
URL.revokeObjectURL(blobUrl);
}
const loading = container.querySelector('[data-rip-loading]');
const stage = container.querySelector('[data-rip-stage]');
if (loading) loading.remove();
if (stage) stage.classList.remove('d-none');
})
.catch((err) => {
console.error('RIP message render failed:', err);
container.innerHTML = `<div class="alert alert-danger m-3">${uiT('ui.echomail.rip_render_failed', 'Failed to render RIPscrip message')}</div>`;
});
}
function showRenderModeToast() {
const modalBody = document.querySelector('#messageModal .modal-body');
if (!modalBody) {
return;
}
const existing = document.getElementById('renderModeToast');
if (existing) {
existing.remove();
}
const modeLabel = window.getViewerModeToastLabel
? window.getViewerModeToastLabel(currentRenderMode, currentMessageData)
: currentRenderMode;
const toast = document.createElement('div');
toast.id = 'renderModeToast';
toast.className = 'badge bg-secondary';
toast.style.position = 'absolute';
toast.style.top = '0.75rem';
toast.style.right = '0.75rem';
toast.style.zIndex = '25';
toast.textContent = `${uiT('ui.echomail.viewer_mode_prefix', 'Viewer mode:')} ${modeLabel}`;
modalBody.appendChild(toast);
window.setTimeout(() => {
const currentToast = document.getElementById('renderModeToast');
if (currentToast) {
currentToast.remove();
}
}, 1200);
}
function updateRenderModeBadge() {
const badge = document.getElementById('ansiRenderBadge');
const badgeText = document.getElementById('ansiRenderBadgeText');
if (!badge || !badgeText) {
return;
}
if (currentRenderMode === 'auto') {
badge.style.display = 'none';
return;
}
const modeLabel = window.getViewerModeToastLabel
? window.getViewerModeToastLabel(currentRenderMode, currentMessageData)
: currentRenderMode;
const prefix = uiT('ui.echomail.viewer_mode_prefix', 'Viewer mode:');
const suffix = uiT('ui.echomail.press_a_to_cycle', 'press A to cycle');
badgeText.textContent = `${prefix} ${modeLabel} ? ${suffix}`;
badge.style.display = '';
}
function renderCurrentMessageBody() {
if (!currentMessageData || !currentParsedMessage) return;
const body = currentParsedMessage.messageBody;
const detectedRipScript = currentMessageData.rip_script
|| ((typeof looksLikeRipScript === 'function' && looksLikeRipScript(body)) ? body : null);
const isRipMode = !!detectedRipScript && (currentRenderMode === 'auto' || currentRenderMode === 'rip');
const container = document.getElementById('messageTextContainer');
if (!container) return;
if (isRipMode) {
renderRipMessageBody(container, detectedRipScript);
updateRenderModeBadge();
return;
}
if (currentRenderMode !== 'plain'
&& currentRenderMode !== 'raw'
&& typeof looksLikeSixel === 'function'
&& looksLikeSixel(body)) {
renderSixelChunks(container, body, function (textChunk) {
return formatMessageBodyForDisplay(currentMessageData, textChunk, currentSearchTerms, {
formatOverride: currentRenderMode === 'plain' ? null : currentRenderMode
});
});
updateRenderModeBadge();
updateSaveToAdLibraryButton();
return;
}
let bodyHtml;
if (currentRenderMode === 'auto' && currentMessageData.markup_html) {
bodyHtml = currentMessageData.markup_html;
} else {
bodyHtml = formatMessageBodyForDisplay(currentMessageData, body, currentSearchTerms, {
forcePlain: currentRenderMode === 'plain',
formatOverride: currentRenderMode === 'plain' ? null : currentRenderMode
});
}
container.innerHTML = '';
const tmp = document.createElement('div');
tmp.innerHTML = bodyHtml;
while (tmp.firstChild) container.appendChild(tmp.firstChild);
if (window.BinkMediaPlayer) BinkMediaPlayer.scan(container, { mediaEnabled: currentMessageData.allow_media !== false });
updateRenderModeBadge();
updateSaveToAdLibraryButton();
}
function isAnsiAdCandidate(message, bodyText) {
const text = bodyText || message?.message_text || '';
if (!text || text.trim() === '') {
return false;
}
const format = window.normalizeArtFormat ? window.normalizeArtFormat(message?.art_format || 'auto') : String(message?.art_format || 'auto').toLowerCase();
if (format === 'ansi' || format === 'amiga_ansi') {
return true;
}
if (format === 'rip' || format === 'plain' || format === 'plain_text' || format === 'text') {
return false;
}
if (typeof looksLikeRipScript === 'function' && looksLikeRipScript(text)) {
return false;
}
const hasAnsi = /\x1b\[[0-9;]*m/.test(text);
const hasCursorAnsi = /\x1b\[[0-9;]*[ABCDEFGHJKfsu]/.test(text);
const hasPipes = /\|[0-9A-Fa-f]{2}/.test(text);
const lines = text.split(/\r?\n/);
const nonEmptyLines = lines.filter(line => line.trim() !== '').length;
const maxLineLength = lines.reduce((max, line) => Math.max(max, line.length), 0);
const linesWithLeadingSpaces = lines.filter(line => /^\s{5,}\S/.test(line)).length;
const hasLeadingSpaceArt = linesWithLeadingSpaces >= 3 && linesWithLeadingSpaces >= (nonEmptyLines * 0.5);
return hasCursorAnsi
|| ((hasAnsi || hasPipes) && nonEmptyLines >= 4 && maxLineLength >= 30)
|| (hasLeadingSpaceArt && nonEmptyLines >= 4 && maxLineLength >= 30);
}
function updateSaveToAdLibraryButton() {
const button = document.getElementById('saveToAdLibraryButton');
if (!button) {
return;
}
const body = currentParsedMessage?.messageBody || currentMessageData?.message_text || '';
const shouldShow = isAnsiAdCandidate(currentMessageData, body);
button.classList.toggle('d-none', !shouldShow);
button.disabled = !shouldShow;
}
function cycleRenderMode() {
currentRenderMode = getNextRenderMode(currentRenderMode);
renderCurrentMessageBody();
showRenderModeToast();
}
function printMessage() {
const content = document.getElementById('messageContent');
if (!content) return;
const win = window.open('', '_blank', 'width=800,height=600');
win.document.write(
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>Print</title>'
+ '<style>'
+ 'body{font-family:sans-serif;font-size:11pt;padding:1.5cm;color:#000;background:#fff}'
+ '.message-header-full{border-bottom:1px solid #ccc;margin-bottom:1em;padding-bottom:.5em}'
+ '.message-header-full strong{color:#333}'
+ 'pre{white-space:pre-wrap;word-break:break-word;font-size:10pt;background:#f8f9fa;border:1px solid #dee2e6;padding:.75em;border-radius:4px}'
+ '.message-origin{border-top:1px solid #ccc;margin-top:1em;padding-top:.5em;font-size:9pt;color:#666}'
+ 'a{color:#000;text-decoration:none}'
+ 'button,i.fas,i.far,.badge,.btn,#ansiRenderBadge,.modal-header-save-icon{display:none!important}'
+ '</style>'
+ '</head><body>'
+ content.innerHTML
+ '</body></html>'
);
win.document.close();
win.focus();
win.onafterprint = function() { win.close(); };
win.print();
}
function openAiAssistantFromMessageModal() {
if (!currentMessageId || typeof openAiAssistant !== 'function') {
return;
}
const messageId = currentMessageId;
$('#messageModal').one('hidden.bs.modal', function() {
openAiAssistant(messageId, 'echomail');
});
$('#messageModal').modal('hide');
}
function downloadCurrentMessage() {
if (!currentMessageId || !currentMessageData) {
return;
}
window.location.href = `/api/messages/echomail/${encodeURIComponent(currentMessageId)}/download`;
}
function saveCurrentMessageToAdLibrary() {
if (!currentMessageId || !currentMessageData || !currentParsedMessage) {
return;
}
if (!isAnsiAdCandidate(currentMessageData, currentParsedMessage.messageBody || currentMessageData.message_text || '')) {
showError(uiT('ui.echomail.save_to_ad_library_not_ansi', 'This message is not eligible to save as an ANSI ad.'));
return;
}
const button = document.getElementById('saveToAdLibraryButton');
if (button) {
button.disabled = true;
}
fetch(`/api/messages/echomail/${encodeURIComponent(currentMessageId)}/save-ad`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
.then(readJsonResponse)
.then(({ response, data }) => {
if (!response.ok || !data.success) {
throw new Error(apiError(data, uiT('ui.echomail.save_to_ad_library_failed', 'Failed to save message to ad library')));
}
const successMessage = window.getApiMessage
? window.getApiMessage(data, uiT('ui.echomail.save_to_ad_library_saved', 'Message saved to ad library.'))
: uiT('ui.echomail.save_to_ad_library_saved', 'Message saved to ad library.');
showSuccess(successMessage);
})
.catch(error => {
showError(error.message || uiT('ui.echomail.save_to_ad_library_failed', 'Failed to save message to ad library'));
})
.finally(() => {
if (button) {
button.disabled = false;
}
});
}
function checkAndDisplayEchomailMessage(message, parsedMessage) {
// Use REPLYTO priority for checking existing contacts
const replyToAddress = message.replyto_address || message.from_address;
const replyToName = message.replyto_name || message.from_name;
// Check if this contact already exists in address book
$.get('/api/address-book', { search: replyToName })
.done(function(response) {
let isInAddressBook = false;
if (response.success && response.entries) {
const existingEntry = response.entries.find(entry =>
entry.messaging_user_id && replyToName &&
entry.messaging_user_id.toLowerCase() === replyToName.toLowerCase() &&
entry.node_address === replyToAddress
);
isInAddressBook = !!existingEntry;
}
renderEchomailMessageContent(message, parsedMessage, isInAddressBook);
})
.fail(function() {
// On error, assume not in address book
renderEchomailMessageContent(message, parsedMessage, false);
});
}
/**
* Parse a ^AFILEREF kludge from a raw kludge_lines string.
* Format: \x01FILEREF: <area_tag[@domain]> <filename> [<sha256>]
* Returns { areaTag, filename, hash } or null if not present.
*/
function parseFileRefKludge(kludgeLines) {
if (!kludgeLines) return null;
const match = kludgeLines.match(/\x01FILEREF:\s+(\S+)\s+(\S+)(?:\s+([0-9a-fA-F]{64}))?/);
if (!match) return null;
return {
areaTag: match[1],
filename: match[2],
hash: match[3] || null,
};
}
/**
* Build an informational banner when the current message is a file comment.
*/
function buildFileRefBanner(kludgeLines) {
const ref = parseFileRefKludge(kludgeLines);
if (!ref) return '';
const label = uiT('ui.echomail.fileref_label', 'File comment:');
const area = escapeHtml(ref.areaTag);
const file = escapeHtml(ref.filename);
// Strip @domain from area tag for the files page area filter (tag-only lookup)
const bareTag = ref.areaTag.split('@')[0];
// Link to the files page with the area pre-selected via query string
const href = `/files?area=${encodeURIComponent(bareTag)}&search=${encodeURIComponent(ref.filename)}`;
return `
<div class="alert alert-info py-2 px-3 mb-3 d-flex align-items-center gap-2" style="font-size:.875rem;">
<i class="fas fa-paperclip"></i>
<span>${label}</span>
<a href="${href}" class="fw-bold text-decoration-none">${file}</a>
<span class="opacity-75">in</span>
<span class="badge bg-info text-dark font-monospace">${area}</span>
</div>
`;
}
function renderEchomailMessageContent(message, parsedMessage, isInAddressBook) {
currentParsedMessage = parsedMessage;
currentRenderMode = 'auto';
hideKeyboardHelp();
let addressBookButton;
const saveAdEligible = isAnsiAdCandidate(message, parsedMessage.messageBody || message.message_text || '');
let saveAdButton = '';
if (isInAddressBook) {
addressBookButton = `
<button class="btn btn-sm btn-outline-secondary ms-2" id="saveAddressBookBtn" disabled title="${uiT('ui.common.already_in_address_book', 'Already in address book')}">
<i class="fas fa-check"></i> <i class="fas fa-address-book"></i>
</button>
`;
} else {
// Use REPLYTO priority for address book save
const replyToAddress = message.replyto_address || message.from_address;
const replyToName = message.replyto_name || message.from_name;
addressBookButton = `
<button class="btn btn-sm btn-outline-success ms-2" id="saveAddressBookBtn" onclick="saveToAddressBook('${escapeHtml(replyToName)}', '${escapeHtml(replyToAddress)}', '${escapeHtml(message.from_name)}', '${escapeHtml(message.from_address)}')" title="${uiT('ui.common.save_to_address_book', 'Save to address book')}">
<i class="fas fa-address-book"></i>
</button>
`;
}
if (document.getElementById('editMessageButton')) {
saveAdButton = `
<button class="btn btn-sm btn-outline-secondary ${saveAdEligible ? '' : 'd-none'}" id="saveToAdLibraryButton" onclick="saveCurrentMessageToAdLibrary()" title="${uiT('ui.echomail.save_to_ad_library_title', 'Save to ad library')}">
<i class="fas fa-bullhorn me-1"></i>${uiT('ui.echomail.save_to_ad_library', 'Save Ad')}
</button>
`;
}
const html = `
<div class="message-header-full mb-3">
<div class="row align-items-center">
<div class="col-md-4 d-flex align-items-center gap-1 flex-wrap">
<strong>${uiT('ui.common.from_label', 'From:')}</strong> <span id="senderNamePopoverTrigger" style="cursor:pointer;">${escapeHtml(message.from_name)}</span>
${addressBookButton}
</div>
<div class="col-md-4">
<strong>${uiT('ui.common.to_label', 'To:')}</strong> ${escapeHtml(message.to_name || uiT('ui.common.all', 'All'))}
</div>
<div class="col-md-4">
<strong>${uiT('ui.common.area_label', 'Area:')}</strong> ${escapeHtml(message.echoarea)}${message.domain ? '@' + escapeHtml(message.domain) : ''}
</div>
</div>
<div class="row mt-2">
<div class="col-md-4">
<strong>${uiT('ui.common.date_label', 'Date:')}</strong> <span title="${uiT('ui.common.received_prefix', 'Received:')} ${formatFullDate(message.date_received)}">${formatFullDate(message.date_written)}</span>
</div>
<div class="col-md-8">
<strong>${uiT('ui.common.subject_label', 'Subject:')}</strong> ${escapeHtml(message.subject || uiT('messages.no_subject', '(No Subject)'))}
</div>
</div>
<div class="row mt-2 align-items-center">
<div class="col-md-6 d-flex align-items-center gap-2">
${saveAdButton}
</div>
<div class="col-md-6 text-end">
<i class="fas fa-bookmark modal-header-save-icon ${message.is_saved == 1 ? 'text-warning' : 'text-muted'}"
id="modalHeaderSaveIcon"
style="cursor: pointer;"
title="${message.is_saved == 1 ? uiT('ui.common.remove_from_saved', 'Remove from saved') : uiT('ui.common.save_for_later', 'Save for later')}"></i>
${message.is_shared == 1 ? `<i class="fas fa-share-alt text-success ms-2" title="${uiT('ui.common.shared', 'Shared')}"></i>` : ''}
</div>
</div>
</div>
<div id="kludgeContainer" class="kludge-lines mb-3" style="display: none;">
<pre class="bg-dark text-light p-3 rounded small">${formatKludgeLinesWithSeparator(parsedMessage.topKludges || parsedMessage.kludgeLines, parsedMessage.bottomKludges || [])}</pre>
</div>
${buildFileRefBanner(message.kludge_lines || '')}
<div class="message-text">
<div id="ansiRenderBadge" style="display:none;" class="mb-2">
<span class="badge bg-secondary" id="ansiRenderBadgeText"></span>
</div>
<div id="messageTextContainer"></div>
</div>
${message.origin_line ? `<div class="message-origin mt-2"><small class="text-muted">${escapeHtml(message.origin_line)}</small></div>` : ''}
`;
$('#messageContent').html(html);
const echoNetmailAddr = (message.replyto_address && message.replyto_address !== '') ? message.replyto_address : message.from_address;
const echoNetmailName = (message.replyto_name && message.replyto_name !== '') ? message.replyto_name : message.from_name;
initSenderPopover(message, echoNetmailAddr, echoNetmailName);
// Update save button state AFTER HTML is inserted
updateModalSaveButton(message);
renderCurrentMessageBody();
// Set up reply button
$('#replyButton').show().off('click').on('click', function() {
// Store the message ID for the reply
const messageId = currentMessageId;
// Hide modal and wait for it to be fully closed before navigating
$('#messageModal').one('hidden.bs.modal', function() {
// Small delay to ensure other modal handlers complete first
setTimeout(function() {
composeMessage('echomail', messageId);
}, 10);
});
$('#messageModal').modal('hide');
});
$('#repostButton').show().off('click').on('click', function() {
const messageId = currentMessageId;
$('#messageModal').one('hidden.bs.modal', function() {
setTimeout(function() {
repostMessage(messageId);
}, 10);
});
$('#messageModal').modal('hide');
});
// Set up share button
$('#shareButton').show().off('click').on('click', function() {
showShareDialog(currentMessageId);
});
}
function composeMessage(type, replyToId = null) {
let url = `/compose/echomail`;
const params = new URLSearchParams();
if (replyToId) {
params.append('reply', replyToId);
}
if (currentInterestId && currentInterestSlug) {
// Interest mode: let the compose page filter areas to this interest
params.append('interest', currentInterestSlug);
} else {
if (currentEchoarea) {
params.append('echoarea', currentEchoarea);
}
if (typeof domain !== 'undefined' && domain) {
params.append('domain', domain);
}
}
if (params.toString()) {
url += '?' + params.toString();
}
window.location.href = url;
}
function repostMessage(messageId) {
if (!messageId) {
return;
}
let url = `/compose/echomail`;
const params = new URLSearchParams();
params.append('repost', messageId);
params.append('return_to', `${window.location.pathname}${window.location.search}`);
window.location.href = `${url}?${params.toString()}`;
}
function forwardMessageByNetmail(messageId) {
if (!messageId) {
return;
}
const url = `/compose/netmail`;
const params = new URLSearchParams();
params.append('repost', messageId);
params.append('source_type', 'echomail');
params.append('return_to', `${window.location.pathname}${window.location.search}`);
window.location.href = `${url}?${params.toString()}`;
}
function searchMessages() {
currentConversationMessageId = null;
currentConversationSubject = '';
// Get query from both desktop and mobile search inputs
let query = $('#searchInput').val().trim();
if (!query) {
query = $('#mobileSearchInput').val().trim();
}
if (query.length < 2) {
showError(uiT('errors.messages.search.query_too_short', 'Please enter at least 2 characters to search'));
return;
}
showLoading('#messagesContainer');
// Store search terms for highlighting
currentSearchTerms = query.toLowerCase().split(/\s+/).filter(term => term.length > 1);
let url = `/api/messages/search?q=${encodeURIComponent(query)}&type=echomail`;
if (currentEchoarea) {
url += `&echoarea=${encodeURIComponent(currentEchoarea)}`;
}
$.get(url)
.done(function(data) {
displayMessages(data.messages);
$('#pagination').empty();
// Store original filter counts if not already stored
if (!originalFilterCounts) {
originalFilterCounts = {
all: parseInt($('#allCount').text()) || 0,
unread: parseInt($('#unreadCount').text()) || 0,
read: parseInt($('#readCount').text()) || 0,
tome: parseInt($('#toMeCount').text()) || 0,
saved: parseInt($('#savedCount').text()) || 0,
drafts: parseInt($('#draftsCount').text()) || 0
};
}
// Update echo area counts with search results
if (data.echoarea_counts) {
searchResultCounts = data.echoarea_counts;
isSearchActive = true;
updateEchoareaCountsWithSearchResults();
showClearSearchButton();
}
// Update filter counts with search results
if (data.filter_counts) {
searchFilterCounts = data.filter_counts;
updateFilterCounts(data.filter_counts);
}
// Collapse mobile search after searching
$('#mobileSearchCollapse').collapse('hide');
})
.fail(function() {
$('#messagesContainer').html('<div class="p-3 text-danger"><i class="fas fa-exclamation-triangle me-2"></i>' + uiT('ui.echomail.search.failed', 'Search failed') + '</div>');
$('#pagination').empty();
});
}
function searchMessagesFromMobile() {
// Copy mobile search input to desktop input for consistency
const query = $('#mobileSearchInput').val().trim();
$('#searchInput').val(query);
searchMessages();
}
function openAdvancedSearch() {
$('#advSearchFromName').val('');
$('#advSearchSubject').val('');
$('#advSearchBody').val('');
$('#advSearchDateFrom').val('');
$('#advSearchDateTo').val('');
$('#advSearchError').addClass('d-none').text('');
$('#advancedSearchModal').modal('show');
}
function runAdvancedSearch() {
const fromName = $('#advSearchFromName').val().trim();
const subject = $('#advSearchSubject').val().trim();
const body = $('#advSearchBody').val().trim();
const messageId = $('#advSearchMessageId').val().trim();
const dateFrom = $('#advSearchDateFrom').val();
const dateTo = $('#advSearchDateTo').val();
const textFields = [fromName, subject, body, messageId].filter(v => v.length > 0);
const hasDate = dateFrom || dateTo;
// Validate: at least one field filled, and text fields must be 2+ chars each
if (textFields.length === 0 && !hasDate) {
$('#advSearchError')
.removeClass('d-none')
.text(window.t('ui.common.advanced_search.fill_one_field', {}, 'Please fill in at least one field (minimum 2 characters for text fields).'));
return;
}
if (textFields.some(v => v.length < 2)) {
$('#advSearchError')
.removeClass('d-none')
.text(window.t('ui.common.advanced_search.fill_one_field', {}, 'Please fill in at least one field (minimum 2 characters for text fields).'));
return;
}
$('#advSearchError').addClass('d-none');
$('#advancedSearchModal').modal('hide');
showLoading('#messagesContainer');
// Collect text search terms for highlighting
currentSearchTerms = [fromName, subject, body, messageId]
.filter(v => v.length > 0)
.join(' ')
.toLowerCase()
.split(/\s+/)
.filter(term => term.length > 1);
const params = new URLSearchParams({ type: 'echomail' });
if (fromName) params.set('from_name', fromName);
if (subject) params.set('subject', subject);
if (body) params.set('body', body);
if (messageId) params.set('message_id', messageId);
if (dateFrom) params.set('date_from', dateFrom);
if (dateTo) params.set('date_to', dateTo);
if (currentEchoarea) params.set('echoarea', currentEchoarea);
$.get('/api/messages/search?' + params.toString())
.done(function(data) {
displayMessages(data.messages);
$('#pagination').empty();
if (!originalFilterCounts) {
originalFilterCounts = {
all: parseInt($('#allCount').text()) || 0,
unread: parseInt($('#unreadCount').text()) || 0,
read: parseInt($('#readCount').text()) || 0,
tome: parseInt($('#toMeCount').text()) || 0,
saved: parseInt($('#savedCount').text()) || 0,
drafts: parseInt($('#draftsCount').text()) || 0
};
}
if (data.echoarea_counts) {
searchResultCounts = data.echoarea_counts;
isSearchActive = true;
updateEchoareaCountsWithSearchResults();
showClearSearchButton();
}
if (data.filter_counts) {
searchFilterCounts = data.filter_counts;
updateFilterCounts(data.filter_counts);
}
$('#mobileSearchCollapse').collapse('hide');
})
.fail(function() {
$('#messagesContainer').html('<div class="p-3 text-danger"><i class="fas fa-exclamation-triangle me-2"></i>' + uiT('ui.echomail.search.failed', 'Search failed') + '</div>');
$('#pagination').empty();
});
}
function updateEchoareaCountsWithSearchResults() {
if (!searchResultCounts) return;
// Create a map of echoarea counts by tag@domain
const countMap = {};
searchResultCounts.forEach(area => {
const areaDomain = (area.domain || '').toString().trim();
const fullTag = areaDomain ? `${area.tag}@${areaDomain}` : area.tag;
countMap[fullTag] = area.message_count;
});
// Update the allEchoareas array with search counts
allEchoareas.forEach(area => {
const areaDomain = (area.domain || '').toString().trim();
const fullTag = areaDomain ? `${area.tag}@${areaDomain}` : area.tag;
area.search_count = countMap[fullTag] || 0;
});
// Re-display with search counts
applyEchoareaFilter();
}
function showClearSearchButton() {
// Add clear search button if not already present
if ($('#clearSearchBtn').length === 0) {
const clearBtn = `
<button id="clearSearchBtn" class="btn btn-sm btn-secondary w-100 mt-2" onclick="clearSearch()">
<i class="fas fa-times me-1"></i> ${uiT('ui.common.clear_search', 'Clear Search')}
</button>
`;
$('#searchInput').after(clearBtn);
}
// Also add to mobile
if ($('#mobileClearSearchBtn').length === 0) {
const mobileClearBtn = `
<button id="mobileClearSearchBtn" class="btn btn-sm btn-secondary w-100 mt-2" onclick="clearSearch()">
<i class="fas fa-times me-1"></i> ${uiT('ui.common.clear_search', 'Clear Search')}
</button>
`;
$('#mobileSearchInput').after(mobileClearBtn);
}
}
function clearSearch() {
// Clear search inputs
$('#searchInput').val('');
$('#mobileSearchInput').val('');
// Clear search state
currentSearchTerms = [];
searchResultCounts = null;
searchFilterCounts = null;
isSearchActive = false;
// Remove clear buttons
$('#clearSearchBtn').remove();
$('#mobileClearSearchBtn').remove();
// Remove search counts from echoareas
allEchoareas.forEach(area => {
delete area.search_count;
});
// Restore original filter counts
if (originalFilterCounts) {
updateFilterCounts(originalFilterCounts);
originalFilterCounts = null;
}
// Reload messages and redisplay echoareas
loadMessages();
applyEchoareaFilter();
}
// Toggle save status of a message
function toggleSaveMessage(messageId, messageType, isSaved) {
if (typeof event !== 'undefined' && event && typeof event.stopPropagation === 'function') {
event.stopPropagation();
}
const method = isSaved ? 'DELETE' : 'POST';
const url = `/api/messages/${messageType}/${messageId}/save`;
$.ajax({
url: url,
type: method,
success: function(response) {
if (response.success) {
// Update the icon in the UI
const icon = $(`.save-btn[data-message-id="${messageId}"]`);
if (isSaved) {
// Message was unsaved
icon.removeClass('text-warning').addClass('text-muted');
icon.attr('title', uiT('ui.common.save_for_later', 'Save for later'));
icon.attr('data-saved', 'false');
icon.attr('onclick', `toggleSaveMessage(${messageId}, '${messageType}', false)`);
showSuccess(uiT('ui.echomail.saved_items.removed', 'Message removed from saved items'));
} else {
// Message was saved
icon.removeClass('text-muted').addClass('text-warning');
icon.attr('title', uiT('ui.common.remove_from_saved', 'Remove from saved'));
icon.attr('data-saved', 'true');
icon.attr('onclick', `toggleSaveMessage(${messageId}, '${messageType}', true)`);
showSuccess(uiT('ui.echomail.saved_items.saved', 'Message saved for later'));
}
// If we're viewing saved messages, remove the message from view
if (isSaved && currentFilter === 'saved') {
loadMessages();
}
} else {
showError(window.getApiErrorMessage
? window.getApiErrorMessage(response, uiT('ui.echomail.save_status.update_failed', 'Failed to update save status'))
: (response.message || uiT('ui.echomail.save_status.update_failed', 'Failed to update save status')));
}
},
error: function(xhr) {
let response = {};
try {
response = JSON.parse(xhr.responseText || '{}');
} catch (e) {
response = xhr.responseJSON || {};
}
showError(apiError(response, uiT('ui.echomail.save_status.update_failed', 'Failed to update save status')));
}
});
}
// Update modal save button based on message save status
function updateModalSaveButton(message) {
const saveBtn = $('#modalSaveButton');
const saveText = $('#modalSaveText');
const saveIcon = saveBtn.find('i');
// Also update header save icon
const headerIcon = $('#modalHeaderSaveIcon');
if (message.is_saved == 1) {
// Message is saved - show "Saved" button that will unsave when clicked
saveBtn.removeClass('btn-outline-warning').addClass('btn-warning');
saveIcon.removeClass('fa-bookmark').addClass('fa-bookmark');
saveText.text(uiT('ui.common.saved_short', 'Saved'));
saveBtn.attr('title', uiT('ui.common.remove_from_saved', 'Remove from saved'));
// Update header icon
headerIcon.removeClass('text-muted').addClass('text-warning');
headerIcon.attr('title', uiT('ui.common.remove_from_saved', 'Remove from saved'));
// Click handlers to UNSAVE (isSaved = true)
saveBtn.off('click').on('click', function() {
toggleSaveMessageModal(message.id, 'echomail', true);
});
headerIcon.off('click').on('click', function() {
toggleSaveMessageModal(message.id, 'echomail', true);
});
} else {
// Message is not saved - show "Save" button that will save when clicked
saveBtn.removeClass('btn-warning').addClass('btn-outline-warning');
saveIcon.removeClass('fa-bookmark').addClass('fa-bookmark');
saveText.text(uiT('ui.common.save', 'Save'));
saveBtn.attr('title', uiT('ui.common.save_for_later', 'Save for later'));
// Update header icon
headerIcon.removeClass('text-warning').addClass('text-muted');
headerIcon.attr('title', uiT('ui.common.save_for_later', 'Save for later'));
// Click handlers to SAVE (isSaved = false)
saveBtn.off('click').on('click', function() {
toggleSaveMessageModal(message.id, 'echomail', false);
});
headerIcon.off('click').on('click', function() {
toggleSaveMessageModal(message.id, 'echomail', false);
});
}
}
// Toggle save status from modal
function toggleSaveMessageModal(messageId, messageType, isSaved) {
const method = isSaved ? 'DELETE' : 'POST';
const url = `/api/messages/${messageType}/${messageId}/save`;
$.ajax({
url: url,
type: method,
success: function(response) {
if (response.success) {
// Update modal button and header icon
const saveBtn = $('#modalSaveButton');
const saveText = $('#modalSaveText');
const saveIcon = saveBtn.find('i');
const headerIcon = $('#modalHeaderSaveIcon');
if (isSaved) {
// Message was unsaved
saveBtn.removeClass('btn-warning').addClass('btn-outline-warning');
saveText.text(uiT('ui.common.save', 'Save'));
saveBtn.attr('title', uiT('ui.common.save_for_later', 'Save for later'));
// Update header icon
headerIcon.removeClass('text-warning').addClass('text-muted');
headerIcon.attr('title', uiT('ui.common.save_for_later', 'Save for later'));
showSuccess(uiT('ui.echomail.saved_items.removed', 'Message removed from saved items'));
// Update click handlers for next time
saveBtn.off('click').on('click', function() {
toggleSaveMessageModal(messageId, messageType, false);
});
headerIcon.off('click').on('click', function() {
toggleSaveMessageModal(messageId, messageType, false);
});
} else {
// Message was saved
saveBtn.removeClass('btn-outline-warning').addClass('btn-warning');
saveText.text(uiT('ui.common.saved_short', 'Saved'));
saveBtn.attr('title', uiT('ui.common.remove_from_saved', 'Remove from saved'));
// Update header icon
headerIcon.removeClass('text-muted').addClass('text-warning');
headerIcon.attr('title', uiT('ui.common.remove_from_saved', 'Remove from saved'));
showSuccess(uiT('ui.echomail.saved_items.saved', 'Message saved for later'));
// Update click handlers for next time
saveBtn.off('click').on('click', function() {
toggleSaveMessageModal(messageId, messageType, true);
});
headerIcon.off('click').on('click', function() {
toggleSaveMessageModal(messageId, messageType, true);
});
}
// Update the corresponding row icon in the message list
const icon = $(`.save-btn[data-message-id="${messageId}"]`);
if (icon.length) {
if (isSaved) {
icon.removeClass('text-warning').addClass('text-muted');
icon.attr('title', uiT('ui.common.save_for_later', 'Save for later'));
icon.attr('data-saved', 'false');
icon.attr('onclick', `toggleSaveMessage(${messageId}, '${messageType}', false)`);
} else {
icon.removeClass('text-muted').addClass('text-warning');
icon.attr('title', uiT('ui.common.remove_from_saved', 'Remove from saved'));
icon.attr('data-saved', 'true');
icon.attr('onclick', `toggleSaveMessage(${messageId}, '${messageType}', true)`);
}
}
// If we're viewing saved messages, remove the message from view
if (isSaved && currentFilter === 'saved') {
loadMessages();
}
} else {
showError(window.getApiErrorMessage
? window.getApiErrorMessage(response, uiT('ui.echomail.save_status.update_failed', 'Failed to update save status'))
: (response.message || uiT('ui.echomail.save_status.update_failed', 'Failed to update save status')));
}
},
error: function(xhr) {
let response = {};
try {
response = JSON.parse(xhr.responseText || '{}');
} catch (e) {
response = xhr.responseJSON || {};
}
showError(apiError(response, uiT('ui.echomail.save_status.update_failed', 'Failed to update save status')));
}
});
}
// Handle browser back/forward
window.addEventListener('popstate', function(event) {
if (event.state && event.state.echoarea !== undefined) {
currentEchoarea = event.state.echoarea;
loadEchoareas();
loadMessages();
}
});
function loadStats() {
console.log('Loading echomail statistics...');
let url;
if (currentInterestId) {
url = '/api/interests/' + encodeURIComponent(currentInterestId) + '/stats';
} else {
url = '/api/messages/echomail/stats';
if (currentEchoarea) {
url += '/' + encodeURIComponent(currentEchoarea);
}
}
$.get(url)
.done(function(data) {
console.log('Echomail stats response:', data);
$('#totalMessages').text(data.total || 0);
$('#unreadMessages').text(data.unread || 0);
$('#recentMessages').text(data.recent || 0);
if (data.areas !== undefined) {
$('#totalAreas').text(data.areas || 0);
} else {
$('#totalAreas').text('-');
}
// Update filter counts if available
if (data.filter_counts) {
updateFilterCounts(data.filter_counts);
}
})
.fail(function(xhr, status, error) {
console.error('Echomail stats loading failed:', xhr.status, status, error);
console.error('Response text:', xhr.responseText);
$('#totalMessages').text(uiT('ui.common.error', 'Error'));
$('#unreadMessages').text(uiT('ui.common.error', 'Error'));
$('#recentMessages').text(uiT('ui.common.error', 'Error'));
$('#totalAreas').text(uiT('ui.common.error', 'Error'));
});
}
// Selection and bulk operations functionality
let selectMode = false;
let selectedMessages = new Set();
function toggleSelectMode() {
selectMode = !selectMode;
const btn = $('#selectModeBtn');
const checkboxColumn = $('#selectAllColumn');
const checkboxCells = $('.message-checkbox');
const bulkActions = $('#bulkActions');
if (selectMode) {
// Enable select mode
btn.html(`<i class="fas fa-times"></i> ${uiT('ui.common.cancel', 'Cancel')}`);
btn.removeClass('btn-outline-secondary').addClass('btn-outline-warning');
checkboxColumn.removeClass('d-none');
checkboxCells.removeClass('d-none');
bulkActions.removeClass('d-none');
} else {
// Disable select mode
btn.html(`<i class="fas fa-check-square"></i> ${uiT('ui.common.select', 'Select')}`);
btn.removeClass('btn-outline-warning').addClass('btn-outline-secondary');
checkboxColumn.addClass('d-none');
checkboxCells.addClass('d-none');
bulkActions.addClass('d-none');
clearSelection();
}
}
function toggleSelectAll() {
const selectAllCheckbox = $('#selectAllMessages');
const messageCheckboxes = $('.message-select');
if (selectAllCheckbox.prop('checked')) {
messageCheckboxes.prop('checked', true);
messageCheckboxes.each(function() {
selectedMessages.add(parseInt($(this).val()));
});
} else {
messageCheckboxes.prop('checked', false);
selectedMessages.clear();
}
updateSelectionDisplay();
}
function updateSelection() {
const messageCheckboxes = $('.message-select');
const checkedBoxes = $('.message-select:checked');
// Update selected messages set
selectedMessages.clear();
checkedBoxes.each(function() {
selectedMessages.add(parseInt($(this).val()));
});
// Update select all checkbox
const selectAllCheckbox = $('#selectAllMessages');
if (checkedBoxes.length === 0) {
selectAllCheckbox.prop('indeterminate', false);
selectAllCheckbox.prop('checked', false);
} else if (checkedBoxes.length === messageCheckboxes.length) {
selectAllCheckbox.prop('indeterminate', false);
selectAllCheckbox.prop('checked', true);
} else {
selectAllCheckbox.prop('indeterminate', true);
selectAllCheckbox.prop('checked', false);
}
updateSelectionDisplay();
}
function updateSelectionDisplay() {
const count = selectedMessages.size;
$('#selectedCount').text(count);
const bulkButtons = $('#bulkActions button');
if (count === 0) {
bulkButtons.prop('disabled', true);
} else {
bulkButtons.prop('disabled', false);
}
}
function clearSelection() {
selectedMessages.clear();
$('.message-select').prop('checked', false);
$('#selectAllMessages').prop('checked', false).prop('indeterminate', false);
updateSelectionDisplay();
if (selectMode) {
toggleSelectMode();
}
}
function markSelectedAsRead() {
if (selectedMessages.size === 0) {
showError(uiT('ui.messages.none_selected', 'No messages selected'));
return;
}
const messageIds = Array.from(selectedMessages);
const markBtn = $('#bulkActions .btn-outline-primary');
const originalText = markBtn.html();
markBtn.prop('disabled', true).html(`<i class="fas fa-spinner fa-spin"></i> ${uiT('ui.echomail.bulk_marking', 'Marking...')}`);
$.ajax({
url: '/api/messages/echomail/read',
type: 'POST',
data: JSON.stringify({ messageIds: messageIds }),
contentType: 'application/json',
success: function(response) {
if (response.success) {
const markedCount = response.marked || messageIds.length;
showSuccess(window.getApiMessage
? window.getApiMessage(response, uiT('ui.echomail.bulk_mark_read_success', 'Marked {count} message(s) as read', { count: markedCount }))
: (response.message || uiT('ui.echomail.bulk_mark_read_success', 'Marked {count} message(s) as read', { count: markedCount })));
clearSelection();
loadMessages();
loadStats();
loadEchoareas();
} else {
showError(apiError(response, uiT('errors.messages.echomail.bulk_read.failed', 'Failed to mark messages as read')));
}
},
error: function(xhr) {
let errorMessage = uiT('ui.echomail.bulk_mark_read_failed', 'Failed to mark messages as read');
try {
const response = JSON.parse(xhr.responseText);
errorMessage = apiError(response, errorMessage);
} catch (e) {
// Use default error message
}
showError(errorMessage);
},
complete: function() {
markBtn.prop('disabled', false).html(originalText);
}
});
}
function deleteSelectedMessages() {
if (!window.isAdmin) {
showError(uiT('errors.messages.echomail.bulk_delete.admin_required', 'Admin privileges required to delete echomail messages'));
return;
}
if (selectedMessages.size === 0) {
showError(uiT('ui.messages.none_selected', 'No messages selected'));
return;
}
const count = selectedMessages.size;
const confirmMessage = uiT(
'ui.echomail.bulk_delete.confirm',
'Are you sure you want to delete {count} selected message(s) for everyone?',
{ count }
);
if (!confirm(confirmMessage)) {
return;
}
// Convert Set to Array for API call
const messageIds = Array.from(selectedMessages);
const savedCount = messageIds.filter(id => {
const msg = currentMessages.find(m => Number(m.id) === Number(id));
return msg && Number(msg.is_saved) === 1;
}).length;
if (savedCount > 0) {
if (!confirm(uiT('ui.echomail.bulk_delete.saved_confirm', '{count} of the selected messages are saved. Are you sure you want to delete them?', { count: savedCount }))) {
return;
}
}
// Show loading state
const deleteBtn = $('#bulkActions .btn-outline-danger');
const originalText = deleteBtn.html();
deleteBtn.prop('disabled', true).html(`<i class="fas fa-spinner fa-spin"></i> ${uiT('ui.echomail.bulk_deleting', 'Deleting...')}`);
$.ajax({
url: '/api/messages/echomail/delete',
type: 'POST',
data: JSON.stringify({ messageIds: messageIds }),
contentType: 'application/json',
success: function(response) {
if (response.success) {
const deletedCount = response.deleted || messageIds.length;
showSuccess(window.getApiMessage
? window.getApiMessage(response, uiT('ui.echomail.bulk_delete.success', 'Deleted {count} message(s)', { count: deletedCount }))
: (response.message || uiT('ui.echomail.bulk_delete.success', 'Deleted {count} message(s)', { count: deletedCount })));
clearSelection();
loadMessages(); // Reload messages
loadStats(); // Update statistics
loadEchoareas(); // Update sidebar unread badges
} else {
showError(apiError(response, uiT('ui.echomail.bulk_delete.failed', 'Failed to delete messages')));
}
},
error: function(xhr) {
let errorMessage = uiT('ui.echomail.bulk_delete.failed', 'Failed to delete messages');
try {
const response = JSON.parse(xhr.responseText);
errorMessage = apiError(response, errorMessage);
} catch (e) {
// Use default error message
}
showError(errorMessage);
},
complete: function() {
deleteBtn.prop('disabled', false).html(originalText);
}
});
}
// Mark message as read when viewed
function markMessageAsRead(messageId) {
$.post(`/api/messages/echomail/${messageId}/read`)
.done(function() {
applyEchomailReadStyle(messageId);
})
.fail(function() {
console.log('Failed to mark message as read');
});
}
function updateMobileAccordionText(selectedArea, interestName) {
const textSpan = $('#mobileAccordionText');
if (!textSpan.length) return;
if (interestName) {
textSpan.text(`${uiT('ui.echomail.viewing_prefix', 'Viewing:')} ${interestName}`);
} else if (selectedArea) {
// Strip domain from display (show just the tag)
const displayTag = selectedArea.includes('@') ? selectedArea.split('@')[0] : selectedArea;
textSpan.text(`${uiT('ui.echomail.viewing_prefix', 'Viewing:')} ${displayTag}`);
} else {
textSpan.text(uiT('ui.echomail.viewing_all_messages', 'Viewing: All Messages'));
}
}
function updateNavigationButtons() {
const prevBtn = $('#prevMessageBtn');
const nextBtn = $('#nextMessageBtn');
if (currentMessageIndex < 0) {
prevBtn.prop('disabled', true);
nextBtn.prop('disabled', true);
prevBtn.attr('title', uiT('ui.common.previous_message', 'Previous message'));
nextBtn.attr('title', uiT('ui.common.next_message', 'Next message'));
return;
}
prevBtn.prop('disabled', currentMessageIndex <= 0);
prevBtn.attr('title', uiT('ui.common.previous_message', 'Previous message'));
const atEnd = currentMessageIndex >= currentMessages.length - 1;
if (atEnd) {
const hasNextPage = currentPagination && currentPagination.page < currentPagination.pages;
if (hasNextPage) {
// More pages available ? keep enabled, navigateMessage will load the next page
nextBtn.prop('disabled', false);
nextBtn.attr('title', uiT('ui.echomail.next_page_title', 'Load next page'));
} else {
// At the true end ? keep enabled so user can trigger the end-of-echo prompt
nextBtn.prop('disabled', false);
nextBtn.attr('title', uiT('ui.echomail.end_of_echo_next_btn_title', 'End of echo'));
}
} else {
nextBtn.prop('disabled', false);
nextBtn.attr('title', uiT('ui.common.next_message', 'Next message'));
}
}
function openRequestedMessage() {
if (!requestedMessageId) {
return;
}
const messageId = requestedMessageId;
requestedMessageId = null;
viewMessage(messageId);
}
/**
* Show an end-of-echo prompt inside the message modal, asking the user
* whether to continue to the next unread echoarea (or just close).
*
* @param {object|null} nextEcho The next echoarea object, or null if none.
*/
function showEndOfEchoPrompt(nextEcho) {
const currentDisplayTag = currentEchoarea
? (currentEchoarea.includes('@') ? currentEchoarea.split('@')[0] : currentEchoarea)
: uiT('ui.echomail.echo_list', 'Echo List');
let bodyHtml = `
<div class="text-center py-4">
<div class="mb-3">
<i class="fas fa-check-circle fa-3x text-success"></i>
</div>
<h5>${uiT('ui.echomail.end_of_echo_title', 'End of {echo}').replace('{echo}', escapeHtml(currentDisplayTag))}</h5>`;
if (nextEcho) {
const nextDisplayTag = nextEcho.tag.includes('@') ? nextEcho.tag.split('@')[0] : nextEcho.tag;
// Build full tag including domain so selectEchoarea hits the correct API path
const nextFullTag = nextEcho.domain ? `${nextEcho.tag}@${nextEcho.domain}` : nextEcho.tag;
bodyHtml += `
<p class="text-muted">${uiT('ui.echomail.end_of_echo_next_prompt', 'Continue to {echo}?').replace('{echo}', `<strong>${escapeHtml(nextDisplayTag)}</strong>`)}</p>
<div class="d-flex justify-content-center gap-2 mt-3">
<button class="btn btn-primary" onclick="proceedToNextEcho(${escapeHtml(JSON.stringify(nextFullTag))})">
<i class="fas fa-arrow-right me-1"></i>${uiT('ui.echomail.end_of_echo_go', 'Go to {echo}').replace('{echo}', escapeHtml(nextDisplayTag))}
</button>
<button class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>${uiT('ui.common.close', 'Close')}
</button>
</div>`;
} else {
bodyHtml += `
<p class="text-muted">${uiT('ui.echomail.end_of_echo_no_next', 'You have no more unread messages.')}</p>
<div class="d-flex justify-content-center mt-3">
<button class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>${uiT('ui.common.close', 'Close')}
</button>
</div>`;
}
bodyHtml += `</div>`;
// Replace modal body content with the prompt
$('#messageContent').html(bodyHtml);
// Update modal title to reflect end-of-echo state
$('#messageSubject').text(uiT('ui.echomail.end_of_echo_title', 'End of {echo}').replace('{echo}', currentDisplayTag));
// Disable nav and toolbar buttons ? no message is displayed
$('#prevMessageBtn').prop('disabled', true);
$('#nextMessageBtn').prop('disabled', true);
$('#editMessageButton, #toggleHeaders, #modalAiAssistantButton, #printMessageButton').prop('disabled', true);
}
/**
* Navigate to the next echoarea after the user confirms the end-of-echo prompt.
*/
function proceedToNextEcho(tag) {
modalClosedByBackButton = true;
$('#messageModal').one('hidden.bs.modal', function() {
selectEchoarea(tag);
});
$('#messageModal').modal('hide');
}
/**
* Find the next echoarea with unread messages after the currently selected one.
* Returns the echoarea object from allEchoareas, or null if none found.
*/
function findNextUnreadEcho() {
if (!allEchoareas || allEchoareas.length === 0) return null;
// currentEchoarea may be "TAG@domain"; area.tag from the API is bare "TAG".
// Normalise both to bare tag for comparison.
const bareCurrentTag = currentEchoarea ? currentEchoarea.split('@')[0] : null;
/**
* Check if an area qualifies as "has unread messages".
* @param {object} area
* @returns {boolean}
*/
function hasUnread(area) {
return (area.unread_count || 0) > 0 && (area.message_count || 0) > 0;
}
if (bareCurrentTag === null) {
// "All Messages" view ? return the first area with unread
return allEchoareas.find(hasUnread) || null;
}
const currentIndex = allEchoareas.findIndex(
area => (area.tag || '').split('@')[0] === bareCurrentTag
);
// Search from the echo after the current one to the end of the list
for (let i = currentIndex + 1; i < allEchoareas.length; i++) {
if (hasUnread(allEchoareas[i])) return allEchoareas[i];
}
// Wrap around: search from the beginning up to (but not including) the current echo
for (let i = 0; i < currentIndex; i++) {
if (hasUnread(allEchoareas[i])) return allEchoareas[i];
}
return null;
}
function updateModalTitle(subject) {
const position = currentMessages.length > 0 ? `${currentMessageIndex + 1} of ${currentMessages.length}` : '';
const titleText = subject || uiT('messages.no_subject', '(No Subject)');
if (position) {
$('#messageSubject').html(`${escapeHtml(titleText)} <small class="text-muted">(${position})</small>`);
} else {
$('#messageSubject').text(titleText);
}
}
function setupModalSwipeNavigation() {
let touchStartX = 0;
let touchStartY = 0;
let touchEndX = 0;
let touchEndY = 0;
let isDragging = false;
let startElement = null;
let startScrollableElement = null;
let startScrollLeft = 0;
const modal = document.getElementById('messageModal');
// Touch start
modal.addEventListener('touchstart', function(e) {
// Only handle if modal is visible
if (!$('#messageModal').hasClass('show')) return;
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
startElement = e.target;
isDragging = false;
// Snapshot the scrollLeft of the nearest scrollable parent at touch start.
// By touchend the browser will have already scrolled the element, so we
// need this value to correctly determine whether the gesture was a scroll
// within content or a navigation swipe.
startScrollableElement = null;
startScrollLeft = 0;
let el = startElement;
while (el && el !== modal) {
if (el.scrollWidth > el.clientWidth) {
const ox = window.getComputedStyle(el).overflowX;
if (ox === 'auto' || ox === 'scroll') {
startScrollableElement = el;
startScrollLeft = el.scrollLeft;
break;
}
}
el = el.parentElement;
}
}, { passive: true });
// Touch move - track dragging to avoid accidental swipes during scrolling
modal.addEventListener('touchmove', function(e) {
if (!$('#messageModal').hasClass('show')) return;
const currentX = e.touches[0].clientX;
const currentY = e.touches[0].clientY;
const deltaX = Math.abs(currentX - touchStartX);
const deltaY = Math.abs(currentY - touchStartY);
// If we're moving more horizontally than vertically, we might be swiping
if (deltaX > deltaY && deltaX > 10) {
isDragging = true;
}
}, { passive: true });
// Touch end - determine if it was a swipe
modal.addEventListener('touchend', function(e) {
if (!$('#messageModal').hasClass('show')) return;
touchEndX = e.changedTouches[0].clientX;
touchEndY = e.changedTouches[0].clientY;
handleSwipe();
}, { passive: true });
function handleSwipe() {
const deltaX = touchEndX - touchStartX;
const deltaY = touchEndY - touchStartY;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
// Minimum swipe distance (in pixels) - increased for better distinction
const minSwipeDistance = 80;
// Require significantly more horizontal than vertical movement
const horizontalRatio = 2.5;
// Must be significantly more horizontal than vertical movement
// And must exceed minimum distance
if (absDeltaX > (absDeltaY * horizontalRatio) && absDeltaX > minSwipeDistance) {
// If the touch started within a horizontally scrollable element, use the
// scroll position captured at touchstart (not the current position, which
// has already been updated by native scrolling during touchmove).
if (startScrollableElement) {
const swipeDirection = deltaX > 0 ? -1 : 1;
const atBoundary = swipeDirection < 0
? startScrollLeft <= 0
: startScrollLeft + startScrollableElement.clientWidth >= startScrollableElement.scrollWidth - 1;
if (!atBoundary) {
// User was scrolling content, not navigating
resetValues();
return;
}
}
// Trigger navigation
if (deltaX > 0) {
// Swipe right - go to previous message
navigateMessage(-1);
} else {
// Swipe left - go to next message
navigateMessage(1);
}
}
resetValues();
}
function resetValues() {
touchStartX = 0;
touchStartY = 0;
touchEndX = 0;
touchEndY = 0;
isDragging = false;
startElement = null;
startScrollableElement = null;
startScrollLeft = 0;
}
}
function navigateMessage(direction) {
if (currentMessages.length === 0) return;
const newIndex = currentMessageIndex + direction;
// Check bounds
if (newIndex < 0) return;
if (newIndex >= currentMessages.length) {
if (direction > 0) {
// More pages available ? load next page and open its first message
if (currentPagination && currentPagination.page < currentPagination.pages) {
currentPage = currentPagination.page + 1;
loadMessages(function() {
if (currentMessages.length > 0) {
viewMessage(currentMessages[0].id);
}
});
return;
}
// No more pages ? refresh echo list so findNextUnreadEcho has current
// unread counts, then show the end-of-echo prompt.
loadEchoareas(function() {
const nextEcho = findNextUnreadEcho();
showEndOfEchoPrompt(nextEcho);
});
}
return;
}
// Get the new message
const newMessage = currentMessages[newIndex];
if (!newMessage) return;
// Skip file items during message navigation
if (newMessage.type === 'file') {
currentMessageIndex = newIndex;
navigateMessage(direction); // continue in the same direction
return;
}
// Update current message info
currentMessageId = newMessage.id;
currentMessageIndex = newIndex;
// Update navigation buttons
updateNavigationButtons();
// Mark as read immediately
markEchomailAsRead(newMessage.id);
// Show loading
$('#messageContent').html(`
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin me-2"></i>
${uiT('ui.common.loading_message', 'Loading message...')}
</div>
`);
// Load the new message
let apiUrl;
if (currentEchoarea) {
apiUrl = `/api/messages/echomail/${encodeURIComponent(currentEchoarea)}/${newMessage.id}`;
} else {
apiUrl = `/api/messages/echomail/message/${newMessage.id}`;
}
$.get(apiUrl)
.done(function(data) {
displayMessageContent(data);
// Auto-scroll to top of modal content
$('#messageModal .modal-body').scrollTop(0);
})
.fail(function() {
$('#messageContent').html(`<div class="text-danger">${uiT('errors.messages.echomail.get_failed', 'Failed to load message')}</div>`);
});
}
// Edit message (admin)
function openEditMessage() {
if (!window.isAdmin || !currentMessageData) return;
const msg = currentMessageData;
const dbId = currentMessageId || msg.id;
$('#editMessageModalTitle').html(`<i class="fas fa-pencil-alt me-2"></i>${uiT('ui.echomail.edit_message', 'Edit Message')} #${dbId}`);
$('#editMsgDbId').text(dbId);
$('#editMsgId').text(msg.message_id || '');
$('#editMsgDate').text(formatFullDate(msg.date_written));
$('#editMsgFrom').text((msg.from_name || '') + (msg.from_address ? ' <' + msg.from_address + '>' : ''));
$('#editMsgSubject').text(msg.subject || '');
$('#editArtFormat').val(msg.art_format || '');
const editCharsetVal = msg.message_charset || 'UTF-8';
const $editCharsetSel = $('#editCharset');
$editCharsetSel.find('option.unknown-charset').remove();
if ($editCharsetSel.find('option[value="' + editCharsetVal + '"]').length === 0) {
$editCharsetSel.prepend('<option value="' + editCharsetVal + '" class="unknown-charset">' + editCharsetVal + ' (unknown)</option>');
}
$editCharsetSel.val(editCharsetVal);
$('#editMessageError').addClass('d-none');
$('#editMessageSuccess').addClass('d-none');
$('#saveEditMessageBtn').prop('disabled', false);
$('#editMessageModal').modal('show');
}
function saveEditMessage() {
if (!window.isAdmin || !currentMessageData) return;
const artFormat = $('#editArtFormat').val();
const charset = $('#editCharset').val().trim();
$('#editMessageError').addClass('d-none');
$('#editMessageSuccess').addClass('d-none');
$('#saveEditMessageBtn').prop('disabled', true);
$.ajax({
url: `/api/messages/echomail/${currentMessageId}/edit`,
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ art_format: artFormat, message_charset: charset }),
}).done(function() {
// Update local cached data so the list badge reflects the change immediately
currentMessageData.art_format = artFormat || null;
currentMessageData.message_charset = charset || null;
const listMsg = currentMessages.find(m => m.id == currentMessageId);
if (listMsg) {
listMsg.art_format = artFormat || null;
}
// Refresh the list row
displayMessages(currentMessages, currentMessages.some(m => m.thread_level > 0));
renderCurrentMessageBody();
$('#editMessageSuccess').removeClass('d-none');
$('#saveEditMessageBtn').prop('disabled', false);
}).fail(function(xhr) {
const payload = xhr.responseJSON || {};
$('#editMessageError').text(window.getApiErrorMessage ? window.getApiErrorMessage(payload, uiT('errors.messages.echomail.edit.save_failed', 'Failed to save changes')) : (payload.error || uiT('errors.messages.echomail.edit.save_failed', 'Failed to save changes'))).removeClass('d-none');
$('#saveEditMessageBtn').prop('disabled', false);
});
}
/**
* Format a file size in bytes to a human-readable string.
*
* @param {number} bytes
* @returns {string}
*/
function formatFileSize(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let v = parseInt(bytes);
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return (i === 0 ? v : v.toFixed(1)) + ' ' + units[i];
}
/**
* Open the file info modal and load details from the API.
*
* @param {number} fileId
*/
function viewFile(fileId) {
openFileInfoModal(fileId);
}
// Sharing functionality
function showShareDialog(messageId) {
currentMessageId = messageId;
// Reset modal state
$('#shareResult').addClass('d-none');
$('#shareError').addClass('d-none');
$('#shareExpiryInfo').hide();
$('#shareAccessInfo').hide();
$('#shareExpirySection').show();
$('#shareDescriptionSection').show();
$('#shareDescription').val('');
$('#createShareBtn').removeClass('d-none');
$('#friendlyUrlBtn').addClass('d-none');
$('#revokeShareBtn').addClass('d-none');
$('#publicShare').prop('checked', true);
$('#shareExpiry').val('');
$('#otherSharesSection').addClass('d-none');
$('#otherSharesList').empty().off('click');
// Check if message is already shared
$.get(`/api/messages/echomail/${messageId}/shares`)
.done(function(data) {
// Show links created by other users above the creation form
if (data.other_shares && data.other_shares.length > 0) {
let html = '';
data.other_shares.forEach(function(share) {
const url = escapeHtml(share.share_url);
const byText = uiT('ui.echomail.shares.by_user', 'by {username}', { username: escapeHtml(share.shared_by_username) });
html += `<div class="d-flex align-items-center gap-2 mb-1">` +
`<input type="text" class="form-control form-control-sm" value="${url}" readonly>` +
`<button class="btn btn-sm btn-outline-secondary copy-other-share-btn" data-url="${url}" title="${uiT('ui.common.copy', 'Copy')}">` +
`<i class="fas fa-copy"></i></button>` +
`<small class="text-muted text-nowrap">${byText}</small>` +
`</div>`;
});
$('#otherSharesList').html(html).on('click', '.copy-other-share-btn', function() {
const url = $(this).data('url');
navigator.clipboard.writeText(url).then(function() {
showSuccess(uiT('ui.echomail.shares.url_copied', 'Share URL copied to clipboard!'));
});
});
$('#otherSharesSection').removeClass('d-none');
}
// Show the current user's own existing share if one exists
if (data.my_shares && data.my_shares.length > 0) {
const share = data.my_shares[0];
$('#shareUrl').val(share.share_url);
$('#shareResult').removeClass('d-none');
$('#shareExpirySection').hide();
$('#shareDescriptionSection').hide();
$('#createShareBtn').addClass('d-none');
if (!share.has_friendly_url) {
$('#friendlyUrlBtn').removeClass('d-none');
}
$('#revokeShareBtn').removeClass('d-none');
$('#publicShare').prop('checked', share.is_public);
// Show expiry information
if (share.expires_at) {
const expiresDate = new Date(share.expires_at);
const now = new Date();
const createdDate = new Date(share.created_at);
if (expiresDate > now) {
const diffMs = expiresDate - now;
const diffHours = Math.ceil(diffMs / (1000 * 60 * 60));
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
let expiryText;
if (diffHours < 24) {
expiryText = `Expires in ${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
} else {
const userDateFormat = window.userSettings?.date_format || 'en-US';
expiryText = `Expires in ${diffDays} day${diffDays !== 1 ? 's' : ''} (${expiresDate.toLocaleString(userDateFormat)})`;
}
$('#shareExpiryText').text(expiryText);
$('#shareExpiryInfo').show().removeClass('alert-warning').addClass('alert-info');
} else {
$('#shareExpiryText').text('This share link has expired');
$('#shareExpiryInfo').show().removeClass('alert-info').addClass('alert-warning');
}
const diffHours = Math.round((expiresDate - createdDate) / (1000 * 60 * 60));
$('#shareExpiry').val(diffHours.toString());
} else {
$('#shareExpiryText').text('This link never expires');
$('#shareExpiryInfo').show().removeClass('alert-warning').addClass('alert-info');
$('#shareExpiry').val('');
}
// Show access information
const accessCount = share.access_count || 0;
const userDateFormat = window.userSettings?.date_format || 'en-US';
const lastAccessed = share.last_accessed_at ? new Date(share.last_accessed_at).toLocaleString(userDateFormat) : 'Never';
$('#shareAccessText').text(`Accessed ${accessCount} time${accessCount !== 1 ? 's' : ''}. Last accessed: ${lastAccessed}`);
$('#shareAccessInfo').show();
// Populate OG image preview if one exists
if (share.og_image_slug) {
populateShareOgImage(window.location.origin + '/shared-image/' + share.og_image_slug);
}
}
$('#shareModal').modal('show');
})
.fail(function() {
showError(uiT('ui.echomail.shares.check_failed', 'Failed to check existing shares'));
});
}
function createShare() {
const publicShare = $('#publicShare').is(':checked');
const expiryHours = $('#shareExpiry').val();
// Disable button and show loading
const btn = $('#createShareBtn');
const originalText = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Creating...');
// Hide previous errors
$('#shareError').addClass('d-none');
const descriptionText = $('#shareDescription').val().trim();
$.ajax({
url: `/api/messages/echomail/${currentMessageId}/share`,
method: 'POST',
data: JSON.stringify({
public: publicShare,
expires_hours: expiryHours || null,
ai_og_summary: descriptionText || null
}),
contentType: 'application/json',
success: function(data) {
if (data.success) {
$('#shareUrl').val(data.share_url);
$('#shareResult').removeClass('d-none');
$('#createShareBtn').addClass('d-none');
$('#revokeShareBtn').removeClass('d-none');
populateShareOgImage(null);
if (data.existing) {
showSuccess(uiT('ui.echomail.shares.using_existing', 'Using existing share link'));
} else {
showSuccess(uiT('ui.echomail.shares.created_success', 'Share link created successfully!'));
}
} else {
$('#shareErrorMessage').text(apiError(data, uiT('errors.messages.share_create_failed', 'Failed to create share link')));
$('#shareError').removeClass('d-none');
}
},
error: function(xhr) {
let errorMessage = uiT('errors.messages.share_create_failed', 'Failed to create share link');
try {
const response = JSON.parse(xhr.responseText);
errorMessage = apiError(response, errorMessage);
} catch (e) {
// Use default error message
}
$('#shareErrorMessage').text(errorMessage);
$('#shareError').removeClass('d-none');
},
complete: function() {
btn.prop('disabled', false).html(originalText);
}
});
}
function generateAiShareSummary() {
const btn = $('#aiShareSummaryBtn');
const icon = $('#aiShareSummaryIcon');
if (!btn.length || btn.prop('disabled')) return;
btn.prop('disabled', true);
icon.removeClass('fa-robot').addClass('fa-spinner fa-spin');
$.ajax({
url: `/api/messages/echomail/${currentMessageId}/share-summary`,
method: 'POST',
contentType: 'application/json',
success: function(data) {
if (data.success && data.summary) {
$('#shareDescription').val(data.summary);
} else {
showError(uiT('ui.share.ai_summary_failed', 'Could not generate a summary. Please try again.'));
}
},
error: function() {
showError(uiT('ui.share.ai_summary_failed', 'Could not generate a summary. Please try again.'));
},
complete: function() {
btn.prop('disabled', false);
icon.removeClass('fa-spinner fa-spin').addClass('fa-robot');
}
});
}
$(document).on('click', '#aiShareSummaryBtn', function() {
generateAiShareSummary();
});
function generateFriendlyUrl() {
const btn = $('#friendlyUrlBtn');
const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Generating...');
$.post(`/api/messages/echomail/${currentMessageId}/share/friendly-url`)
.done(function(data) {
if (data.success) {
$('#shareUrl').val(data.share_url);
$('#friendlyUrlBtn').addClass('d-none');
showSuccess(uiT('ui.echomail.shares.friendly_url_generated', 'Friendly URL generated!'));
} else {
showError(apiError(data, uiT('ui.echomail.shares.friendly_url_failed', 'Failed to generate friendly URL')));
btn.prop('disabled', false).html(originalHtml);
}
})
.fail(function() {
showError(uiT('ui.echomail.shares.friendly_url_failed', 'Failed to generate friendly URL'));
btn.prop('disabled', false).html(originalHtml);
});
}
function revokeShare() {
if (!confirm(uiT('ui.echomail.shares.revoke_confirm', 'Are you sure you want to revoke this share link? It will no longer be accessible to others.'))) {
return;
}
const btn = $('#revokeShareBtn');
const originalText = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Revoking...');
$.ajax({
url: `/api/messages/echomail/${currentMessageId}/share`,
method: 'DELETE',
success: function(data) {
if (data.success) {
$('#shareResult').addClass('d-none');
$('#createShareBtn').removeClass('d-none');
$('#revokeShareBtn').addClass('d-none');
showSuccess(uiT('ui.echomail.shares.revoked', 'Share link revoked'));
} else {
showError(apiError(data, uiT('errors.messages.share_revoke_failed', 'Failed to revoke share link')));
}
},
error: function() {
showError(uiT('errors.messages.share_revoke_failed', 'Failed to revoke share link'));
},
complete: function() {
btn.prop('disabled', false).html(originalText);
}
});
}
function copyShareUrl() {
const shareUrl = $('#shareUrl').val();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(shareUrl).then(function() {
showSuccess(uiT('ui.echomail.shares.url_copied', 'Share URL copied to clipboard!'));
// Briefly highlight the input field
$('#shareUrl').select();
}).catch(function() {
// Fallback for older browsers
fallbackCopyTextToClipboard(shareUrl);
});
} else {
// Fallback for older browsers
fallbackCopyTextToClipboard(shareUrl);
}
}
function fallbackCopyTextToClipboard(text) {
const textArea = document.createElement('textarea');
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = '0';
textArea.style.left = '0';
textArea.style.position = 'fixed';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
showSuccess(uiT('ui.echomail.shares.url_copied', 'Share URL copied to clipboard!'));
} else {
showError(uiT('ui.common.copy_failed_manual', 'Copy to clipboard failed. Please copy manually.'));
}
} catch (err) {
showError(uiT('ui.common.copy_not_supported_manual', 'Copy to clipboard not supported. Please copy manually.'));
}
document.body.removeChild(textArea);
}
function populateShareOgImage(ogImageUrl) {
if (ogImageUrl) {
$('#shareOgImageThumb').attr('src', ogImageUrl + '?t=' + Date.now());
$('#shareOgImagePreview').removeClass('d-none');
$('#shareOgImageUploadControls').addClass('d-none');
$('#shareOgImageRemoveControls').removeClass('d-none');
} else {
$('#shareOgImageThumb').attr('src', '');
$('#shareOgImagePreview').addClass('d-none');
$('#shareOgImageUploadControls').removeClass('d-none');
$('#shareOgImageInput').val('');
$('#shareOgImageRemoveControls').addClass('d-none');
}
}
function uploadShareOgImage() {
const file = $('#shareOgImageInput')[0].files[0];
if (!file) {
showError(uiT('ui.share.og_image_no_file', 'Please select an image file first.'));
return;
}
const btn = $('#shareOgImageUploadBtn');
const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i>' + uiT('ui.share.og_image_uploading', 'Uploading...'));
$('#shareOgImageInput').prop('disabled', true);
$('#shareOgImageRemoveBtn').prop('disabled', true);
const formData = new FormData();
formData.append('image', file);
$.ajax({
url: `/api/messages/echomail/${currentMessageId}/share/image`,
method: 'POST',
data: formData,
contentType: false,
processData: false,
success: function(data) {
if (data.success) {
const ogUrl = window.location.origin + '/shared-image/' + data.og_image_slug;
populateShareOgImage(ogUrl);
$('#shareOgImageInput').val('');
showSuccess(uiT('ui.share.og_image_upload_success', 'Preview image updated'));
} else {
showError(apiError(data, uiT('errors.messages.share.image_upload_failed', 'Failed to upload preview image')));
}
},
error: function(xhr) {
let payload = {};
try { payload = JSON.parse(xhr.responseText); } catch (e) {}
showError(apiError(payload, uiT('errors.messages.share.image_upload_failed', 'Failed to upload preview image')));
},
complete: function() {
btn.prop('disabled', false).html(originalHtml);
$('#shareOgImageInput').prop('disabled', false);
$('#shareOgImageRemoveBtn').prop('disabled', false);
}
});
}
function removeShareOgImage() {
const btn = $('#shareOgImageRemoveBtn');
const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>');
$.ajax({
url: `/api/messages/echomail/${currentMessageId}/share/image`,
method: 'DELETE',
success: function(data) {
if (data.success) {
populateShareOgImage(null);
showSuccess(uiT('ui.share.og_image_remove_success', 'Preview image removed'));
} else {
showError(apiError(data, uiT('errors.messages.share.image_remove_failed', 'Failed to remove preview image')));
btn.prop('disabled', false).html(originalHtml);
}
},
error: function(xhr) {
let payload = {};
try { payload = JSON.parse(xhr.responseText); } catch (e) {}
showError(apiError(payload, uiT('errors.messages.share.image_remove_failed', 'Failed to remove preview image')));
btn.prop('disabled', false).html(originalHtml);
}
});
}
// Event handlers for share modal
$(document).ready(function() {
$('#createShareBtn').on('click', createShare);
$('#friendlyUrlBtn').on('click', generateFriendlyUrl);
$('#revokeShareBtn').on('click', revokeShare);
$('#shareOgImageUploadBtn').on('click', uploadShareOgImage);
$('#shareOgImageRemoveBtn').on('click', removeShareOgImage);
// Reset modal when closed
$('#shareModal').on('hidden.bs.modal', function() {
$('#shareResult').addClass('d-none');
$('#shareError').addClass('d-none');
$('#createShareBtn').removeClass('d-none');
$('#friendlyUrlBtn').addClass('d-none').prop('disabled', false).html('<i class="fas fa-link"></i> Get Friendly URL');
$('#revokeShareBtn').addClass('d-none');
$('#shareOgImageInput').val('');
populateShareOgImage(null);
});
});
// Save sender to address book from message modal
// ?? Echomail list from-name popover ??????????????????????????????????????????
function handleEchoFromClick(el) {
showFtnSenderPopover(el, {
fromName: el.dataset.fromName || '',
fromAddress: el.dataset.fromAddress || '',
toAddress: el.dataset.toAddress || '',
toName: el.dataset.toName || '',
subject: el.dataset.subject || '',
placement: 'bottom',
siblingSelector: '.echo-from-popover',
});
}
$(document).on('click.echoFromPopoverDismiss', function(e) {
if (!$(e.target).closest('.echo-from-popover, .popover').length) {
document.querySelectorAll('.echo-from-popover').forEach(function(el) {
const p = bootstrap.Popover.getInstance(el);
if (p) p.hide();
});
}
});
// ?????????????????????????????????????????????????????????????????????????????
function saveToAddressBook(fromName, fromAddress, originalFromName, originalFromAddress) {
const button = $('#saveAddressBookBtn');
const originalHtml = button.html();
const originalTitle = button.attr('title');
// Show loading state
button.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>').attr('title', 'Saving...');
// First check if this contact already exists
$.get('/api/address-book', { search: fromName })
.done(function(response) {
if (response.success) {
// Check if contact already exists
const existingEntry = response.entries.find(entry =>
entry.messaging_user_id && fromName &&
entry.messaging_user_id.toLowerCase() === fromName.toLowerCase() &&
entry.node_address === fromAddress
);
if (existingEntry) {
// Already exists - show "already saved" state
button.removeClass('btn-outline-success').addClass('btn-outline-secondary')
.html('<i class="fas fa-check"></i> <i class="fas fa-address-book"></i>')
.attr('title', uiT('ui.common.already_in_address_book', 'Already in address book'))
.prop('disabled', true);
showError(uiT('ui.address_book.already_exists', 'This contact is already in your address book'));
return;
}
// Contact doesn't exist, create new entry
// Build description with reference information
let description = 'Added from echomail message';
if (originalFromName && originalFromAddress) {
if (fromName !== originalFromName || fromAddress !== originalFromAddress) {
// Using REPLYTO data
description = `Added from echomail message. REPLYTO: ${fromName} (${fromAddress}), Original sender: ${originalFromName} (${originalFromAddress})`;
} else {
// Using original sender data
description = `Added from echomail message. Sender: ${originalFromName} (${originalFromAddress})`;
}
}
const data = {
name: originalFromName, // Use original from_name for descriptive name (e.g., "Aug")
messaging_user_id: fromName, // Use REPLYTO name for messaging
node_address: fromAddress, // Use REPLYTO address for messaging
description: description
};
$.ajax({
url: '/api/address-book/',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(data),
success: function(response) {
if (response.success) {
// Show success state
button.removeClass('btn-outline-success').addClass('btn-success')
.html(`<i class="fas fa-check"></i> ${uiT('ui.common.saved_short', 'Saved')}`)
.attr('title', uiT('ui.address_book.saved_to_address_book', 'Saved to address book'))
.prop('disabled', true);
showSuccess(uiT('ui.address_book.sender_added', `${fromName} added to address book`, { name: fromName }));
// Refresh address book in sidebar if it exists (for netmail page)
if (typeof loadAddressBook === 'function') {
loadAddressBook();
}
} else {
// Reset button on error
button.removeClass('btn-outline-success').addClass('btn-outline-danger')
.html(originalHtml)
.attr('title', uiT('ui.common.error_click_retry', 'Error - click to retry'))
.prop('disabled', false);
showError(apiError(response, uiT('errors.address_book.create_failed', 'Failed to save to address book')));
}
},
error: function(xhr) {
// Reset button on error
button.removeClass('btn-outline-success').addClass('btn-outline-danger')
.html(originalHtml)
.attr('title', uiT('ui.common.error_click_retry', 'Error - click to retry'))
.prop('disabled', false);
showError(apiError(xhr.responseJSON, uiT('errors.address_book.create_failed', 'Failed to save to address book')));
}
});
} else {
// Reset button on error
button.html(originalHtml).attr('title', originalTitle).prop('disabled', false);
showError(uiT('ui.address_book.check_existing_failed', 'Failed to check existing contacts'));
}
})
.fail(function() {
// Reset button on error
button.html(originalHtml).attr('title', originalTitle).prop('disabled', false);
showError(uiT('ui.address_book.check_existing_failed', 'Failed to check existing contacts'));
});
}
// User settings functions - apply echomail-specific settings after loading
function loadEchomailSettings() {
const settingsPromise = typeof window.loadUserSettings === 'function'
? window.loadUserSettings().then(function() {
// Apply echomail-specific settings
userSettings = window.userSettings;
if (userSettings.threaded_view !== undefined) {
threadedView = userSettings.threaded_view;
const toggleText = $('#threadingToggleText');
if (threadedView) {
toggleText.text('Show Flat');
} else {
toggleText.text('Show Threaded');
}
}
if (userSettings.default_sort) {
currentSort = userSettings.default_sort;
}
updateSortIndicator();
})
: Promise.resolve();
// Load per-area page positions from DB (only if setting is enabled)
const positionsPromise = $.get('/api/user/web-mail-state')
.then(function(data) {
if (!window.userSettings || !window.userSettings.remember_page_position) return;
if (data && data.settings && data.settings.web_echomail_positions) {
try {
const parsed = typeof data.settings.web_echomail_positions === 'string'
? JSON.parse(data.settings.web_echomail_positions)
: data.settings.web_echomail_positions;
if (parsed && typeof parsed === 'object') {
echoPageMemory = parsed;
}
} catch (e) {}
}
})
.catch(function() {});
return Promise.all([settingsPromise, positionsPromise]);
}
/**
* Persist the current echoPageMemory to the DB (fire-and-forget).
*/
function saveEchoPositions() {
if (!window.userSettings || !window.userSettings.remember_page_position) return;
$.ajax({
url: '/api/user/web-mail-state',
method: 'POST',
data: JSON.stringify({ web_echomail_positions: echoPageMemory }),
contentType: 'application/json'
});
}
// Use global settings functions directly - no local wrappers needed
// All calls to saveUserSetting and saveUserSettings will use window.* functions
// Draft management functions
function continueDraft(draftId) {
// Navigate to compose page with draft data
window.location.href = `/compose/echomail?draft=${draftId}`;
}
function deleteDraftConfirm(draftId) {
if (confirm(uiT('ui.drafts.delete_confirm', 'Are you sure you want to delete this draft? This cannot be undone.'))) {
deleteDraft(draftId);
}
}
function deleteDraft(draftId) {
$.ajax({
url: `/api/messages/drafts/${draftId}`,
method: 'DELETE',
success: function(response) {
if (response.success) {
// Reload drafts to show updated list
loadDrafts();
const successMessage = window.getApiMessage
? window.getApiMessage(response, uiT('ui.drafts.deleted_success', 'Draft deleted successfully'))
: uiT('ui.drafts.deleted_success', 'Draft deleted successfully');
showSuccess(successMessage);
} else {
showError(uiT('errors.messages.drafts.delete_failed', 'Failed to delete draft'));
}
},
error: function() {
showError(uiT('errors.messages.drafts.delete_failed', 'Failed to delete draft'));
}
});
}
|