PHP Classes

File: templates/compose.twig

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   templates/compose.twig   Download  
File: templates/compose.twig
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 141,781 bytes
 

Contents

Class file image Download
?{% extends "base.twig" %} {% block title %}{{ t('ui.compose.title_prefix', {}, locale, ['common']) }} {{ type|title }} - {{ parent() }}{% endblock %} {% block head %} <link rel="stylesheet" href="/vendor/toastui-editor-3.2.2/toastui-editor.css"> <link rel="stylesheet" href="/vendor/toastui-editor-3.2.2/toastui-editor-dark.css"> <style> .md-paste-area { border: 2px dashed var(--bs-border-color); border-radius: 6px; padding: 1.5rem; text-align: center; cursor: default; min-height: 80px; display: flex; flex-direction: column; align-items: center; justify-content: center; transition: border-color 0.2s; } .md-paste-area.paste-ready { border-color: var(--bs-success); } /* TUI Editor ? hide its own toolbar, we use our Bootstrap toolbar */ .toastui-editor-defaultUI-toolbar, .toastui-editor-toolbar { display: none !important; } /* Override the dark theme's fixed backgrounds so the page background shows through */ #tuiEditorEl .toastui-editor-dark.toastui-editor-defaultUI, #tuiEditorEl .toastui-editor-dark .toastui-editor-ww-container, #tuiEditorEl .toastui-editor-dark .toastui-editor-md-container, #tuiEditorEl .toastui-editor-dark .toastui-editor-mode-switch, #tuiEditorEl .toastui-editor-dark .toastui-editor-mode-switch .tab-item.active { background-color: transparent !important; background: transparent !important; } #tuiEditorEl .toastui-editor-dark.toastui-editor-defaultUI { border-color: var(--bs-border-color) !important; } /* Inline code */ #tuiEditorEl .toastui-editor-contents code { background-color: rgba(128,128,128,0.2) !important; } </style> {% endblock %} {% block content %} <div class="d-flex justify-content-between align-items-center mb-4"> <h2> <i class="fas fa-{{ type == 'netmail' ? 'envelope' : 'comments' }}"></i> {{ t('ui.compose.title_prefix', {}, locale, ['common']) }} {{ type|title }} </h2> <a href="{% if type == 'echomail' and interest_slug is defined and interest_slug %}/echomail?interest={{ interest_slug|url_encode }}{% elseif type == 'echomail' and echoarea is defined and echoarea %}/echomail/{{ echoarea }}{% else %}/{{ type }}{% endif %}" class="btn btn-outline-secondary"> <i class="fas fa-arrow-left"></i> {{ t('ui.compose.back_to_prefix', {}, locale, ['common']) }} {{ type|title }} </a> </div> <div class="row" id="composeRow"> <div class="col-lg-8" id="composeMain"> <div class="compose-form"> <form id="composeForm"> <input type="hidden" id="messageType" value="{{ type }}"> <input type="hidden" id="replyToId" value="{{ reply_to_id }}"> <input type="hidden" id="domain" value="{{ domain }}"> <input type="hidden" id="returnTo" value="{{ return_to }}"> {% if type == 'netmail' %} <div class="row mb-3"> <div class="col-md-6"> <label for="toAddress" class="form-label">{{ t('ui.compose.to_address_label', {}, locale, ['common']) }}</label> <div class="position-relative"> <div class="input-group"> <input type="text" class="form-control" id="toAddress" name="to_address" placeholder="" value="{{ reply_to_address }}" autocomplete="off"> <button class="btn btn-outline-secondary dropdown-toggle" type="button" id="addressBookButton" data-bs-toggle="dropdown" aria-expanded="false" title="{{ t('ui.compose.address_book.select_title', {}, locale, ['common']) }}"> <i class="fas fa-address-book"></i> </button> <ul class="dropdown-menu dropdown-menu-end" id="addressBookDropdown" style="min-width: 300px;"> <li><h6 class="dropdown-header d-flex justify-content-between align-items-center"> {{ t('ui.address_book.title', {}, locale, ['common']) }} <button class="btn btn-sm btn-outline-primary" onclick="showAddAddressModal()" title="{{ t('ui.compose.address_book.create_title', {}, locale, ['common']) }}"> <i class="fas fa-plus" ></i> </button> </h6></li> <li><hr class="dropdown-divider"></li> <li class="px-3"> <input type="text" class="form-control form-control-sm" id="composeAddressSearch" placeholder="{{ t('ui.address_book.search_placeholder', {}, locale, ['common']) }}" onkeyup="searchAddressBookForCompose(this.value)"> </li> <li><hr class="dropdown-divider"></li> <div id="composeAddressList" style="max-height: 200px; overflow-y: auto;"> <li><div class="text-center text-muted py-2"> <i class="fas fa-spinner fa-spin"></i> {{ t('ui.common.loading', {}, locale, ['common']) }} </div></li> </div> </ul> </div> <div id="nodeSearchDropdown" class="dropdown-menu w-100" style="display:none; top:100%; left:0; position:absolute; z-index:1050; max-height:220px; overflow-y:auto; overflow-x:hidden;"></div> </div> <div class="form-text">{{ t('ui.compose.fidonet_address_help', {}, locale, ['common']) }}</div> </div> <div class="col-md-6"> <label for="toName" class="form-label">{{ t('ui.compose.to_name_required', {}, locale, ['common']) }}</label> <div class="position-relative"> <input type="text" class="form-control" id="toName" name="to_name" value="{{ to_name_value }}" required autocomplete="off"> <div id="nameSearchDropdown" class="dropdown-menu w-100" style="display:none; top:100%; left:0; position:absolute; z-index:1050; max-height:220px; overflow-y:auto; overflow-x:hidden;"></div> </div> </div> </div> {% if crashmail_enabled %} <div class="mb-3"> <div class="form-check"> <input type="checkbox" class="form-check-input" id="crashmail" name="crashmail"{% if prefill_crashmail %} checked{% endif %}> <label class="form-check-label" for="crashmail"> <i class="fas fa-bolt text-warning me-1"></i>{{ t('ui.compose.crashmail.direct_delivery_label', {}, locale, ['common']) }}{% if credits_enabled %} - <strong>{{ crashmail_cost }} {{ currency_symbol }}</strong>{% endif %} </label> <div class="form-text"> {{ t('ui.compose.crashmail.help_direct', {}, locale, ['common']) }} {{ t('ui.compose.crashmail.help_reachable', {}, locale, ['common']) }}{% if credits_enabled %} {{ t('ui.compose.crashmail.help_cost', {'cost': crashmail_cost, 'currency': currency_symbol}, locale, ['common']) }}{% endif %} </div> </div> </div> <div class="mb-3"{% if not freq_experimental_enabled %} style="display:none;"{% endif %}> <div class="form-check"> <input type="checkbox" class="form-check-input" id="freqCheckbox" name="is_freq"> <label class="form-check-label" for="freqCheckbox"> <i class="fas fa-file-download text-info me-1"></i>{{ t('ui.compose.freq.label', {}, locale, ['common']) }} </label> <div class="form-text">{{ t('ui.compose.freq.help', {}, locale, ['common']) }}</div> </div> </div> <div id="attachmentSection" class="mb-3" style="display:none"> <label class="form-label"> <i class="fas fa-paperclip me-1"></i>{{ t('ui.compose.attach_file', {}, locale, ['common']) }} <small class="text-muted">({{ t('ui.compose.attach_file_help_short', {}, locale, ['common']) }})</small> </label> <input type="file" id="attachmentFile" class="form-control"> <div id="attachmentName" class="form-text"></div> </div> {% endif %} {% else %} <div class="row mb-3"> <div class="col-md-6"> <label for="echoarea" class="form-label"> {{ t('ui.compose.echo_area_required', {}, locale, ['common']) }} {% if interest_slug is defined and interest_slug %} <span class="badge bg-secondary ms-1"> <i class="fas fa-layer-group me-1"></i>{{ interest_name }} </span> {% endif %} </label> <select class="form-select" id="echoarea" name="echoarea" required> <option value="">{{ t('ui.compose.select_echo_area', {}, locale, ['common']) }}</option> <!-- Options will be loaded via AJAX or from interest context --> </select> </div> <div class="col-md-6"> <label for="toName" class="form-label">{{ t('ui.compose.to_name', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="toName" name="to_name" value="{{ to_name_value }}" placeholder="All"> <div class="form-text">{{ t('ui.compose.to_name_public_help', {}, locale, ['common']) }}</div> </div> </div> <div class="mb-3" id="crossPostSection" style="display: none;"> <a class="text-decoration-none" data-bs-toggle="collapse" href="#crossPostCollapse" role="button" aria-expanded="false" aria-controls="crossPostCollapse"> <i class="fas fa-layer-group me-1"></i>{{ t('ui.compose.cross_post_to_other_areas', {}, locale, ['common']) }} <span class="badge bg-secondary ms-1" id="crossPostBadge" style="display: none;">0/{{ max_cross_post_areas }}</span> </a> <div class="collapse mt-2" id="crossPostCollapse"> <div class="card card-body p-2" style="max-height: 200px; overflow-y: auto;" id="crossPostList"> </div> <div class="form-text">{{ t('ui.compose.cross_post_help_prefix', {}, locale, ['common']) }} {{ max_cross_post_areas }}). {{ t('ui.compose.cross_post_help_suffix', {}, locale, ['common']) }}</div> </div> </div> {% endif %} <div class="mb-3"> <label for="subject" class="form-label">{{ t('ui.compose.subject_required', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="subject" name="subject" value="{{ reply_subject }}" required> </div> <div class="mb-3" id="messageTextGroup"> <label for="messageText" class="form-label">{{ t('ui.compose.message_required', {}, locale, ['common']) }}</label> <!-- Plaintext Toolbar (shown in plain text mode, hidden when StyleCodes active) --> <div id="plaintextToolbar" class="sc-editor-toolbar mb-1" role="toolbar" aria-label="Plain text formatting"> <div class="btn-group btn-group-sm"> <button type="button" id="insertImageBtn" class="btn btn-outline-secondary" title="{{ t('ui.compose.insert_image', {}, locale, ['common']) }}"> <i class="fas fa-image"></i> </button> </div> </div> <!-- StyleCodes Toolbar (shown when StyleCodes markup is selected) --> <div id="stylecodesToolbar" style="display: none;" class="sc-editor-toolbar mb-1" role="toolbar" aria-label="StyleCodes formatting"> <div class="btn-group btn-group-sm"> <button type="button" class="btn btn-outline-secondary" onclick="scAction('bold')" title="Bold (*bold*)"><strong>B</strong></button> <button type="button" class="btn btn-outline-secondary" onclick="scAction('italic')" title="Italic (/italic/)"><em>I</em></button> <button type="button" class="btn btn-outline-secondary" onclick="scAction('underline')" title="Underline (_underline_)"><u>U</u></button> <button type="button" class="btn btn-outline-secondary" onclick="scAction('inverse')" title="{{ t('ui.compose.stylecodes.inverse_title', {}, locale, ['common']) }}">Inv</button> </div> <div class="btn-group btn-group-sm"> <button type="button" id="insertImageBtnSc" class="btn btn-outline-secondary" title="{{ t('ui.compose.insert_image', {}, locale, ['common']) }}"> <i class="fas fa-image"></i> </button> </div> </div> <textarea class="form-control" id="messageText" name="message_text" rows="15" required>{{ reply_text }}</textarea> <input type="file" id="insertFileInput" style="display: none;"> <div class="d-flex justify-content-between align-items-center mt-1"> <div class="form-text mb-0" id="messageTextHint"> {{ t('ui.compose.plain_text_hint', {}, locale, ['common']) }} </div> <div class="d-flex gap-1"> <button type="button" id="insertFileBtn" class="btn btn-sm btn-outline-secondary" title="{{ t('ui.compose.insert_file', {}, locale, ['common']) }}"> <i class="fa fa-file-import"></i> {{ t('ui.compose.insert_file', {}, locale, ['common']) }} </button> <button type="button" class="btn btn-sm btn-outline-secondary" id="advancedOptionsToggle" data-bs-toggle="collapse" data-bs-target="#advancedOptionsPanel" aria-expanded="false" aria-controls="advancedOptionsPanel"> <i class="fas fa-sliders-h me-1"></i>{{ t('ui.compose.advanced_options', {}, locale, ['common']) }} </button> </div> </div> <div class="form-text" id="scHint" style="display: none;">{{ t('ui.compose.stylecodes.active_prefix', {}, locale, ['common']) }} <strong>*bold*</strong> &nbsp; <em>/italic/</em> &nbsp; <u>_underline_</u> &nbsp; #inverse#</div> </div> <!-- Markdown Editor (shown when "Send as Markdown" is checked) --> <div class="mb-3" id="markdownEditorContainer" style="display: none;"> <label class="form-label">{{ t('ui.compose.message_required', {}, locale, ['common']) }} <small class="text-muted ms-2"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 208 128" style="width:1.25em;height:0.75em;vertical-align:-0.125em" class="me-1" aria-hidden="true" fill="currentColor"><rect width="208" height="128" rx="16" fill="none" stroke="currentColor" stroke-width="10"/><path d="M30 98V30h20l20 25 20-25h20v68H90V59L70 84 50 59v39zm125 0l-30-33h20V30h20v35h20z"/></svg>{{ t('ui.compose.markdown.mode', {}, locale, ['common']) }}</small></label> <div class="md-editor-wrapper"> <div class="md-editor-toolbar" role="toolbar" aria-label="{{ t('ui.compose.markdown.toolbar_aria', {}, locale, ['common']) }}"> <div class="btn-group btn-group-sm"> <button type="button" class="btn btn-outline-secondary" onclick="mdAction('bold')" title="{{ t('ui.compose.markdown.bold_title', {}, locale, ['common']) }}"><strong>B</strong></button> <button type="button" class="btn btn-outline-secondary" onclick="mdAction('italic')" title="{{ t('ui.compose.markdown.italic_title', {}, locale, ['common']) }}"><em>I</em></button> </div> <div class="btn-group btn-group-sm"> <button type="button" class="btn btn-outline-secondary" onclick="mdAction('h1')" title="{{ t('ui.compose.markdown.heading_1_title', {}, locale, ['common']) }}">H1</button> <button type="button" class="btn btn-outline-secondary" onclick="mdAction('h2')" title="{{ t('ui.compose.markdown.heading_2_title', {}, locale, ['common']) }}">H2</button> <button type="button" class="btn btn-outline-secondary" onclick="mdAction('h3')" title="{{ t('ui.compose.markdown.heading_3_title', {}, locale, ['common']) }}">H3</button> </div> <div class="btn-group btn-group-sm"> <button type="button" class="btn btn-outline-secondary" onclick="mdAction('code')" title="{{ t('ui.compose.markdown.inline_code_title', {}, locale, ['common']) }}"><i class="fas fa-code"></i></button> <button type="button" class="btn btn-outline-secondary" onclick="mdAction('codeblock')" title="{{ t('ui.compose.markdown.code_block_title', {}, locale, ['common']) }}"><i class="fas fa-file-code"></i></button> </div> <div class="btn-group btn-group-sm"> <button type="button" class="btn btn-outline-secondary" onclick="mdAction('link')" title="{{ t('ui.compose.markdown.link_title', {}, locale, ['common']) }}"><i class="fas fa-link"></i></button> <button type="button" class="btn btn-outline-secondary" onclick="showMdImageModal()" title="{{ t('ui.compose.markdown.image_title', {}, locale, ['common']) }}"><i class="fas fa-image"></i></button> </div> <div class="btn-group btn-group-sm"> <button type="button" class="btn btn-outline-secondary" onclick="mdAction('ul')" title="{{ t('ui.compose.markdown.unordered_list_title', {}, locale, ['common']) }}"><i class="fas fa-list-ul"></i></button> <button type="button" class="btn btn-outline-secondary" onclick="mdAction('ol')" title="{{ t('ui.compose.markdown.ordered_list_title', {}, locale, ['common']) }}"><i class="fas fa-list-ol"></i></button> <button type="button" class="btn btn-outline-secondary" onclick="mdAction('quote')" title="{{ t('ui.compose.markdown.blockquote_title', {}, locale, ['common']) }}"><i class="fas fa-quote-left"></i></button> </div> <div class="btn-group btn-group-sm"> <button type="button" class="btn btn-outline-secondary" onclick="mdAction('hr')" title="{{ t('ui.compose.markdown.horizontal_rule_title', {}, locale, ['common']) }}">&#8212;</button> </div> <div class="btn-group btn-group-sm ms-auto"> <button type="button" class="btn btn-outline-secondary" onclick="showMarkdownHelp()" title="{{ t('ui.compose.markdown.quick_reference_title', {}, locale, ['common']) }}"><i class="fas fa-question-circle"></i></button> </div> </div> <div id="tuiEditorEl"></div> </div> <div class="form-text"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 208 128" style="width:1.25em;height:0.75em;vertical-align:-0.125em" class="me-1" aria-hidden="true" fill="currentColor"><rect width="208" height="128" rx="16" fill="none" stroke="currentColor" stroke-width="10"/><path d="M30 98V30h20l20 25 20-25h20v68H90V59L70 84 50 59v39zm125 0l-30-33h20V30h20v35h20z"/></svg>{{ t('ui.compose.markdown.formatting_active', {}, locale, ['common']) }} <a href="#" onclick="showMarkdownHelp(); return false;">{{ t('ui.compose.markdown.quick_reference', {}, locale, ['common']) }}</a></div> </div> <div class="collapse mb-3" id="advancedOptionsPanel"> <div class="card card-body bg-light py-3"> <div class="mb-3"> <label class="form-label" for="charsetSelect">{{ t('ui.compose.charset', {}, locale, ['common']) }}</label> <select class="form-select" id="charsetSelect" name="charset"> {% for cs in supported_charsets %} <option value="{{ cs.value }}">{{ cs.label }}</option> {% endfor %} </select> <div class="form-text">{{ t('ui.compose.charset_help', {}, locale, ['common']) }}</div> </div> <div class="mb-3" id="markdownSection" style="display: none;"> <label class="form-label" for="markupType">{{ t('ui.compose.markup_format', {}, locale, ['common']) }}</label> <select class="form-select" id="markupType" name="markup_type"> <option value="">{{ t('ui.compose.plain_text', {}, locale, ['common']) }}</option> <option value="markdown">{{ t('ui.compose.markdown_label', {}, locale, ['common']) }}</option> <option value="stylecodes">{{ t('ui.compose.stylecodes_label', {}, locale, ['common']) }}</option> </select> <div class="form-text">{{ t('ui.compose.markup_format_help', {}, locale, ['common']) }}</div> </div> <div class="mb-0"> <label class="form-label" for="hardWrapSelect">{{ t('ui.compose.hard_wrap', {}, locale, ['common']) }}</label> <select class="form-select" id="hardWrapSelect"> <option value="79">{{ t('ui.compose.hard_wrap_79', {}, locale, ['common']) }}</option> <option value="39">{{ t('ui.compose.hard_wrap_39', {}, locale, ['common']) }}</option> <option value="0">{{ t('ui.compose.hard_wrap_off', {}, locale, ['common']) }}</option> </select> <div class="form-text">{{ t('ui.compose.hard_wrap_help', {}, locale, ['common']) }}</div> </div> </div> </div> <div class="mb-3"> <label for="taglineSelect" class="form-label">{{ t('ui.compose.select_tagline', {}, locale, ['common']) }}</label> <select class="form-select" id="taglineSelect" name="tagline"> <option value="">{{ t('ui.settings.no_tagline', {}, locale, ['common']) }}</option> {% for tagline in taglines %} <option value="{{ tagline|e('html_attr') }}" {% if default_tagline == tagline %}selected{% endif %}>{{ tagline }}</option> {% endfor %} </select> <div class="form-text">{{ t('ui.compose.tagline_help', {}, locale, ['common']) }}</div> </div> <div id="sendStatus" class="mb-3" style="display: none;"></div> <div class="d-flex justify-content-between"> <button type="button" class="btn btn-outline-secondary" onclick="history.back()"> <i class="fas fa-times"></i> {{ t('ui.common.cancel', {}, locale, ['common']) }} </button> <div> <button type="button" class="btn btn-outline-primary" onclick="saveDraft()"> <i class="fas fa-save"></i> {{ t('ui.compose.save_draft', {}, locale, ['common']) }} </button> {% if license_valid %} <div class="btn-group"> <button type="button" class="btn btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" id="templatesDropdownBtn"> <i class="fas fa-file-alt"></i> {{ t('ui.compose.templates.button', {}, locale, ['common']) }} </button> <ul class="dropdown-menu" id="templatesDropdownMenu"> <li><span class="dropdown-item text-muted small" id="templatesLoadingItem">{{ t('ui.compose.templates.loading', {}, locale, ['common']) }}</span></li> <li><hr class="dropdown-divider" id="templatesDivider" style="display:none"></li> <li><a class="dropdown-item" href="#" onclick="openSaveTemplateModal(); return false;"><i class="fas fa-plus me-1"></i>{{ t('ui.compose.templates.save_current', {}, locale, ['common']) }}</a></li> <li><a class="dropdown-item" href="#" onclick="openManageTemplatesModal(); return false;"><i class="fas fa-cog me-1"></i>{{ t('ui.compose.templates.manage', {}, locale, ['common']) }}</a></li> </ul> </div> {% endif %} <button type="submit" class="btn btn-fidonet"> <i class="fas fa-paper-plane"></i> {{ t('ui.compose.send_prefix', {}, locale, ['common']) }} {{ type|title }} </button> </div> </div> </form> </div> </div> <div class="col-lg-4" id="composeSidebar"> <div class="card"> <div class="card-header d-flex justify-content-between align-items-center"> <h6 class="mb-0">{{ t('ui.compose.message_guidelines', {}, locale, ['common']) }}</h6> <button id="sidebarToggleBtn" class="btn btn-sm btn-outline-secondary" title="{{ t('ui.compose.hide_panel', {}, locale, ['common']) }}"> <i class="fas fa-angles-right" id="sidebarToggleIcon"></i> </button> </div> <div class="card-body"> {% if type == 'netmail' %} <h6>{{ t('ui.compose.netmail_guidelines', {}, locale, ['common']) }}</h6> <ul class="small"> <li>{{ t('ui.compose.netmail_guideline_private', {}, locale, ['common']) }}</li> <li id="netmailPostingIdentityGuideline">{{ t('ui.compose.netmail_guideline_identity', {}, locale, ['common']) }}</li> <li>{{ t('ui.compose.netmail_guideline_addressing', {}, locale, ['common']) }}</li> <li>{{ t('ui.compose.netmail_guideline_respectful', {}, locale, ['common']) }}</li> <li>{{ t('ui.compose.netmail_guideline_routed', {}, locale, ['common']) }}</li> {% if credits_enabled %} <li><strong>{{ t('ui.compose.netmail_cost', {'cost': netmail_cost, 'currency': currency_symbol}, locale, ['common']) }}</strong></li> {% endif %} </ul> {% else %} <h6>{{ t('ui.compose.echomail_guidelines', {}, locale, ['common']) }}</h6> <ul class="small"> <li id="echomailPostingIdentityGuideline">{{ t('ui.compose.echomail_guideline_identity', {}, locale, ['common']) }}</li> <li>{{ t('ui.compose.echomail_guideline_public', {}, locale, ['common']) }}</li> <li>{{ t('ui.compose.echomail_guideline_topic', {}, locale, ['common']) }}</li> <li>{{ t('ui.compose.echomail_guideline_respectful', {}, locale, ['common']) }}</li> <li>{{ t('ui.compose.echomail_guideline_rules', {}, locale, ['common']) }}</li> </ul> {% endif %} <h6 class="mt-3">{{ t('ui.compose.technical_notes', {}, locale, ['common']) }}</h6> <ul class="small"> <li>{{ t('ui.compose.tech_note_plain_text_only', {}, locale, ['common']) }}</li> <li>{{ t('ui.compose.tech_note_line_length', {}, locale, ['common']) }}</li> <li>{{ t('ui.compose.tech_note_origin_line', {}, locale, ['common']) }}</li> </ul> </div> </div> </div> <div id="sidebarShowBtn" class="d-none align-items-start pt-1"> <button class="btn btn-sm btn-outline-secondary" title="{{ t('ui.compose.show_panel', {}, locale, ['common']) }}" onclick="toggleComposeSidebar()"> <i class="fas fa-angles-left"></i> </button> </div> </div> {% endblock %} {% block scripts %} <script src="/vendor/toastui-editor-3.2.2/toastui-editor-all.min.js"></script> <script src="/js/markdown-editor.js"></script> <script> function uiT(key, fallback, params = {}) { if (window.t) { return window.t(key, params, fallback); } return fallback; } const maxCrossPostAreas = {{ max_cross_post_areas|default(5) }}; {% if interest_slug is defined and interest_slug %} const interestEchoareas = {{ interest_echoareas|json_encode|raw }}; {% else %} const interestEchoareas = null; {% endif %} let allEchoareas = []; let markdownSupportTimer = null; // ?? Node address autocomplete ????????????????????????????????????????????????? (function() { const FTN_RE = /^\d+:\d+\/\d+(\.\d+)?$/; let nodeSearchTimer = null; let activeIndex = -1; let currentResults = []; function getDropdown() { return document.getElementById('nodeSearchDropdown'); } function getInput() { return document.getElementById('toAddress'); } function closeDropdown() { const dd = getDropdown(); if (dd) { dd.style.display = 'none'; dd.innerHTML = ''; } activeIndex = -1; currentResults = []; } function renderResults(nodes) { const dd = getDropdown(); if (!dd) return; currentResults = nodes; activeIndex = -1; dd.innerHTML = ''; if (nodes.length === 0) { dd.innerHTML = '<span class="dropdown-item-text text-muted small px-3 py-2">' + uiT('ui.compose.node_search.no_results', 'No nodes found') + '</span>'; } else { nodes.forEach(function(node, i) { const item = document.createElement('a'); item.className = 'dropdown-item text-wrap'; item.href = '#'; const domainBadge = node.domain ? ' <span class="badge bg-secondary ms-1" style="font-size:0.7em;">' + escapeHtml(node.domain) + '</span>' : ''; item.innerHTML = '<strong class="font-monospace">' + escapeHtml(node.address) + '</strong>' + domainBadge + ' &mdash; ' + escapeHtml(node.system_name) + (node.location ? ' <small class="text-muted">(' + escapeHtml(node.location) + ')</small>' : ''); item.addEventListener('mousedown', function(e) { e.preventDefault(); selectNode(node); }); item.dataset.index = i; dd.appendChild(item); }); } dd.style.display = 'block'; } function selectNode(node) { const input = getInput(); if (input) { input.value = node.address; } closeDropdown(); } function highlightItem(newIndex) { const dd = getDropdown(); if (!dd) return; const items = dd.querySelectorAll('a.dropdown-item'); items.forEach(function(el, i) { el.classList.toggle('active', i === newIndex); }); activeIndex = newIndex; } function doSearch(q) { if (q.length < 2) { closeDropdown(); return; } const dd = getDropdown(); if (dd) { dd.innerHTML = '<span class="dropdown-item-text text-muted small px-3 py-2"><i class="fas fa-spinner fa-spin me-1"></i>' + uiT('ui.compose.node_search.searching', 'Searching...') + '</span>'; dd.style.display = 'block'; } fetch('/api/nodelist/search?q=' + encodeURIComponent(q)) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) renderResults(data.nodes || []); }) .catch(function() { closeDropdown(); }); } document.addEventListener('DOMContentLoaded', function() { const input = getInput(); if (!input) return; input.addEventListener('input', function() { const val = input.value.trim(); clearTimeout(nodeSearchTimer); if (val === '' || FTN_RE.test(val)) { closeDropdown(); return; } nodeSearchTimer = setTimeout(function() { doSearch(val); }, 300); }); input.addEventListener('keydown', function(e) { const dd = getDropdown(); if (!dd || dd.style.display === 'none') return; const items = dd.querySelectorAll('a.dropdown-item'); if (e.key === 'ArrowDown') { e.preventDefault(); highlightItem(Math.min(activeIndex + 1, items.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); highlightItem(Math.max(activeIndex - 1, 0)); } else if (e.key === 'Enter' && activeIndex >= 0) { e.preventDefault(); if (currentResults[activeIndex]) selectNode(currentResults[activeIndex]); } else if (e.key === 'Escape') { closeDropdown(); } }); input.addEventListener('blur', function() { // Delay so mousedown on item fires first setTimeout(closeDropdown, 150); }); document.addEventListener('click', function(e) { if (!e.target.closest('#nodeSearchDropdown') && e.target !== input) { closeDropdown(); } }); }); })(); // ????????????????????????????????????????????????????????????????????????????? // ?? Address book To Name autocomplete ????????????????????????????????????????? (function() { // Only active on netmail compose if (!document.getElementById('toAddress')) return; let abSearchTimer = null; let abActiveIndex = -1; let abCurrentResults = []; function getDropdown() { return document.getElementById('nameSearchDropdown'); } function getInput() { return document.getElementById('toName'); } function closeDropdown() { const dd = getDropdown(); if (dd) { dd.style.display = 'none'; dd.innerHTML = ''; } abActiveIndex = -1; abCurrentResults = []; } function renderResults(entries) { const dd = getDropdown(); if (!dd) return; abCurrentResults = entries; abActiveIndex = -1; dd.innerHTML = ''; if (entries.length === 0) { dd.innerHTML = '<span class="dropdown-item-text text-muted small px-3 py-2">' + uiT('ui.address_book.no_entries_found', 'No entries found') + '</span>'; } else { entries.forEach(function(entry, i) { const item = document.createElement('a'); item.className = 'dropdown-item text-wrap py-2'; item.href = '#'; let addrHtml = entry.node_address ? '<span class="font-monospace">' + escapeHtml(entry.node_address) + '</span>' : ''; if (entry.node_domain) { addrHtml += ' <span class="badge bg-secondary ms-1" style="font-size:0.7em;">' + escapeHtml(entry.node_domain) + '</span>'; } if (entry.node_system_name) { addrHtml += ' <span class="text-muted">&mdash; ' + escapeHtml(entry.node_system_name) + '</span>'; } item.innerHTML = '<div class="fw-semibold">' + escapeHtml(entry.name || '') + '</div>' + (addrHtml ? '<div class="small text-muted">' + addrHtml + '</div>' : ''); item.addEventListener('mousedown', function(e) { e.preventDefault(); selectEntry(entry); }); item.dataset.index = i; dd.appendChild(item); }); } dd.style.display = 'block'; } function selectEntry(entry) { const nameInput = getInput(); if (nameInput) nameInput.value = entry.messaging_user_id || ''; const addrInput = document.getElementById('toAddress'); if (addrInput) addrInput.value = entry.node_address || ''; const crashmail = document.getElementById('crashmail'); if (crashmail) crashmail.checked = !!entry.always_crashmail; if (typeof refreshMarkdownSupportForNetmail === 'function') { refreshMarkdownSupportForNetmail(); } closeDropdown(); } function highlightItem(newIndex) { const dd = getDropdown(); if (!dd) return; const items = dd.querySelectorAll('a.dropdown-item'); items.forEach(function(el, i) { el.classList.toggle('active', i === newIndex); }); abActiveIndex = newIndex; } // Synthetic entry for the local sysop ? matched when the user types "sysop". // No node address is set so the message stays local (no FTN routing). {% if system_sysop %} const SYSOP_ENTRY = { name: {{ system_sysop|json_encode|raw }}, node_address: '', node_domain: '', node_system_name: '', always_crashmail: false, messaging_user_id: {{ system_sysop|json_encode|raw }} }; {% else %} const SYSOP_ENTRY = null; {% endif %} function doSearch(q) { if (SYSOP_ENTRY && q.toLowerCase() === 'sysop') { renderResults([SYSOP_ENTRY]); return; } const dd = getDropdown(); if (dd) { dd.innerHTML = '<span class="dropdown-item-text text-muted small px-3 py-2"><i class="fas fa-spinner fa-spin me-1"></i>' + uiT('ui.compose.node_search.searching', 'Searching...') + '</span>'; dd.style.display = 'block'; } fetch('/api/address-book/search/' + encodeURIComponent(q)) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) renderResults(data.entries || []); }) .catch(function() { closeDropdown(); }); } document.addEventListener('DOMContentLoaded', function() { const input = getInput(); if (!input) return; input.addEventListener('input', function() { const val = input.value.trim(); clearTimeout(abSearchTimer); if (val.length < 2) { closeDropdown(); return; } abSearchTimer = setTimeout(function() { doSearch(val); }, 300); }); input.addEventListener('keydown', function(e) { const dd = getDropdown(); if (!dd || dd.style.display === 'none') return; const items = dd.querySelectorAll('a.dropdown-item'); if (e.key === 'ArrowDown') { e.preventDefault(); highlightItem(Math.min(abActiveIndex + 1, items.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); highlightItem(Math.max(abActiveIndex - 1, 0)); } else if (e.key === 'Enter' && abActiveIndex >= 0) { e.preventDefault(); if (abCurrentResults[abActiveIndex]) selectEntry(abCurrentResults[abActiveIndex]); } else if (e.key === 'Escape') { closeDropdown(); } }); input.addEventListener('blur', function() { setTimeout(closeDropdown, 150); }); document.addEventListener('click', function(e) { if (!e.target.closest('#nameSearchDropdown') && e.target !== input) { closeDropdown(); } }); }); })(); // ????????????????????????????????????????????????????????????????????????????? let markdownPrefilled = false; let mdEditorActive = false; let tuiEditor = null; let markdownEditorController = null; let scToolbarActive = false; let hardWrapLimit = 79; // default; 0 = off const replyMarkupType = {{ reply_markup_type|default("")|json_encode|raw }}; const defaultCharset = {{ default_charset|default("CP437")|json_encode|raw }}; const replyCharset = {{ reply_charset|default("")|json_encode|raw }}; const domainCharsets = {{ domain_charsets|default({})|json_encode|raw }}; // Pre-select the charset from the reply context, or the configured default for new messages (function() { const sel = document.getElementById('charsetSelect'); if (sel) { sel.value = replyCharset || defaultCharset; // If the value didn't match any option, fall back to the configured default if (!sel.value) { sel.value = defaultCharset; } } })(); // Charset label ? TextDecoder encoding label const charsetDecoderMap = { 'UTF-8': 'utf-8', 'CP437': 'ibm437', 'CP850': 'ibm850', 'CP852': 'ibm852', 'CP866': 'ibm866', 'ISO-8859-1': 'iso-8859-1', 'ISO-8859-2': 'iso-8859-2' }; $('#insertFileBtn').on('click', function() { $('#insertFileInput').val('').trigger('click'); }); $('#insertImageBtn, #insertImageBtnSc').on('click', function() { showMdImageModal(); }); const MESSAGE_MAX_BYTES = 16384; const MESSAGE_WARN_BYTES = Math.floor(MESSAGE_MAX_BYTES * 0.8); // ~13 KB let messageSizeWarnAcknowledged = false; /** * Estimate the encoded byte size of the message body for the selected charset. * UTF-8 is measured exactly; single-byte charsets use character count as an * upper bound (iconv drops unmappable chars so actual bytes will be ? this). */ function getMessageByteCount() { const text = $('#messageText').val(); const charset = $('#charsetSelect').val() || 'UTF-8'; if (charset === 'UTF-8') { return new TextEncoder().encode(text).length; } // Single-byte charsets: 1 byte per character after encoding return text.length; } $('#insertFileInput').on('change', function() { const file = this.files[0]; if (!file) return; const charsetVal = $('#charsetSelect').val() || 'UTF-8'; const decoderLabel = charsetDecoderMap[charsetVal] || 'utf-8'; const reader = new FileReader(); reader.onload = function(e) { let text; try { text = new TextDecoder(decoderLabel).decode(new Uint8Array(e.target.result)); } catch (err) { // Fallback to UTF-8 if the decoder label is unsupported text = new TextDecoder('utf-8').decode(new Uint8Array(e.target.result)); } const ta = document.getElementById('messageText'); const start = ta.selectionStart; const end = ta.selectionEnd; ta.value = ta.value.substring(0, start) + text + ta.value.substring(end); ta.selectionStart = ta.selectionEnd = start + text.length; ta.focus(); // Keep the hidden textarea in sync if the markdown editor is active if (mdEditorActive && markdownEditorController) { markdownEditorController.setMarkdown(ta.value, false); tuiEditor = markdownEditorController.getToastEditor(); } }; reader.readAsArrayBuffer(file); }); function updateMarkdownSection(allowed) { // Replying to a formatted message: always show the selector so the user // can match the original format, even if the area default is plain text. if (!allowed && replyMarkupType) { allowed = true; } // Loading a draft with a saved markup type: same treatment. if (!allowed && window.draftMeta && window.draftMeta.markup_type) { allowed = true; } const section = $('#markdownSection'); if (allowed) { section.show(); if (replyMarkupType && !markdownPrefilled) { $('#markupType').val(replyMarkupType); markdownPrefilled = true; if (replyMarkupType === 'markdown') { initMarkdownEditor(); } else if (replyMarkupType === 'stylecodes') { initStyleCodesToolbar(); } } else if (window.draftMeta && window.draftMeta.markup_type && !markdownPrefilled) { const mt = window.draftMeta.markup_type; $('#markupType').val(mt); markdownPrefilled = true; if (mt === 'markdown') { initMarkdownEditor(); } else if (mt === 'stylecodes') { initStyleCodesToolbar(); } } } else { if (mdEditorActive) { destroyMarkdownEditor(); } if (scToolbarActive) { destroyStyleCodesToolbar(); } $('#markupType').val(''); section.hide(); } } let currentAddressIsLocal = false; function updateCrashmailForAttachment() { const hasFile = $('#attachmentFile')[0] && $('#attachmentFile')[0].files.length > 0; if (!hasFile) return; if (currentAddressIsLocal) { $('#crashmail').prop('checked', false).prop('disabled', false); } else { $('#crashmail').prop('checked', true).prop('disabled', true); } } function fetchMarkdownSupport(params) { $.get('/api/messages/markdown-support', params) .done(function(response) { updateMarkdownSection(!!response.allowed); const messageType = $('#messageType').val(); if (messageType === 'netmail') { updateNetmailGuidelinesPostingName((response && response.posting_name_policy) || 'real_name'); currentAddressIsLocal = !!response.is_local_address; updateCrashmailForAttachment(); // Update the charset selector to match the destination's uplink charset, // unless the user is composing a reply (where the original charset takes precedence). if (!replyCharset && response.default_charset) { const sel = document.getElementById('charsetSelect'); if (sel) { sel.value = response.default_charset; if (!sel.value) sel.value = defaultCharset; } } } }) .fail(function() { updateMarkdownSection(false); const messageType = $('#messageType').val(); if (messageType === 'netmail') { updateNetmailGuidelinesPostingName('real_name'); currentAddressIsLocal = false; updateCrashmailForAttachment(); } }); } function refreshMarkdownSupportForEchomail() { const selected = $('#echoarea').val(); if (!selected) { updateMarkdownSection(false); return; } const atIdx = selected.indexOf('@'); const tag = atIdx >= 0 ? selected.substring(0, atIdx) : selected; const domain = atIdx >= 0 ? selected.substring(atIdx + 1) : ''; if (!tag) { updateMarkdownSection(false); return; } fetchMarkdownSupport({ area: selected, domain: domain }); } function getSelectedEchoarea() { const selected = ($('#echoarea').val() || '').trim(); if (!selected) { return null; } const atIdx = selected.indexOf('@'); const tag = atIdx >= 0 ? selected.substring(0, atIdx) : selected; const domain = atIdx >= 0 ? selected.substring(atIdx + 1) : ''; return allEchoareas.find(function(area) { const areaTag = (area.tag || '').toString(); const areaDomain = (area.domain || '').toString(); return areaTag.toLowerCase() === tag.toLowerCase() && areaDomain.toLowerCase() === domain.toLowerCase(); }) || null; } function resolveEchomailPostingNamePolicy(area) { const policy = ((area && area.posting_name_policy) ? area.posting_name_policy : '').toString().trim().toLowerCase(); if (policy === 'real_name' || policy === 'username') { return policy; } const effectivePolicy = ((area && area.effective_posting_name_policy) ? area.effective_posting_name_policy : '').toString().trim().toLowerCase(); if (effectivePolicy === 'real_name' || effectivePolicy === 'username') { return effectivePolicy; } return 'real_name'; } function updateEchomailGuidelinesPostingName() { const guideline = $('#echomailPostingIdentityGuideline'); if (!guideline.length) { return; } const selectedArea = getSelectedEchoarea(); if (!selectedArea) { // No area selected ? leave the server-rendered (translated) text untouched. return; } const policy = resolveEchomailPostingNamePolicy(selectedArea); guideline.text(policy === 'username' ? uiT('ui.compose.echomail_guideline_identity_area_username', 'Posting identity for this area: Username/Alias') : uiT('ui.compose.echomail_guideline_identity_area_real_name', 'Posting identity for this area: Real Name')); } function refreshMarkdownSupportForNetmail() { const address = ($('#toAddress').val() || '').trim(); if (!address) { updateMarkdownSection(false); currentAddressIsLocal = true; // blank address routes to local system updateCrashmailForAttachment(); // Blank address = local delivery ? default charset to UTF-8 (no FTN packet encoding needed). if (!replyCharset) { const sel = document.getElementById('charsetSelect'); if (sel) sel.value = 'UTF-8'; } return; } fetchMarkdownSupport({ address: address }); } function updateNetmailGuidelinesPostingName(policy) { const guideline = $('#netmailPostingIdentityGuideline'); if (!guideline.length) { return; } const normalizedPolicy = (policy || '').toString().trim().toLowerCase(); guideline.text(normalizedPolicy === 'username' ? uiT('ui.compose.netmail_guideline_identity_destination_username', 'Posting identity for this destination: Username/Alias') : uiT('ui.compose.netmail_guideline_identity_destination_real_name', 'Posting identity for this destination: Real Name')); } /** * Strip HTML that TUI Editor's WYSIWYG mode injects into its markdown output. * <br> ? newline; all other tags removed. * @param {string} md * @returns {string} */ function sanitizeTuiMarkdown(md) { return window.BinkMarkdownEditor.sanitize(md); } /** * Initialize the TUI WYSIWYG markdown editor, replacing the plain textarea. */ function initMarkdownEditor() { if (mdEditorActive) return; const editorHeight = ($('#messageText').outerHeight() || 300); markdownEditorController = window.BinkMarkdownEditor.mount({ textarea: '#messageText', plainContainer: '#messageTextGroup', editorContainer: '#markdownEditorContainer', editorEl: '#tuiEditorEl', height: editorHeight + 'px', initialEditType: 'wysiwyg', previewStyle: 'tab', toolbarItems: [], onCtrlK: function() { mdAction('link'); }, onBareUrlPaste: function(url) { pendingUnfurlUrl = url; setTimeout(function() { showUnfurlPrompt(); }, 100); } }); tuiEditor = markdownEditorController.getToastEditor(); mdEditorActive = true; } /** * Tear down the TUI editor and restore the plain textarea. */ function destroyMarkdownEditor() { if (!mdEditorActive) return; if (markdownEditorController) { markdownEditorController.destroy(); markdownEditorController = null; } tuiEditor = null; mdEditorActive = false; } /** * Show the StyleCodes toolbar above the plain textarea and attach shortcuts. */ function initStyleCodesToolbar() { if (scToolbarActive) return; scToolbarActive = true; $('#plaintextToolbar').hide(); $('#stylecodesToolbar').show(); $('#scHint').show(); $('#messageTextHint').hide(); $('#messageText').off('keydown.sc').on('keydown.sc', function(e) { if (e.ctrlKey || e.metaKey) { if (e.key === 'b') { e.preventDefault(); scAction('bold'); } if (e.key === 'i') { e.preventDefault(); scAction('italic'); } } }); } /** * Hide the StyleCodes toolbar and remove its keyboard shortcuts. */ function destroyStyleCodesToolbar() { if (!scToolbarActive) return; scToolbarActive = false; $('#stylecodesToolbar').hide(); $('#plaintextToolbar').show(); $('#scHint').hide(); $('#messageTextHint').show(); $('#messageText').off('keydown.sc'); } /** * Apply a StyleCode wrapper around the current selection in #messageText. * @param {string} action - 'bold', 'italic', 'underline', or 'inverse' */ function scAction(action) { const ta = document.getElementById('messageText'); if (!ta) return; const ch = { bold: '*', italic: '/', underline: '_', inverse: '#' }[action]; if (!ch) return; const ph = { bold: 'bold text', italic: 'italic text', underline: 'underlined text', inverse: 'inverse text' }[action]; const start = ta.selectionStart; const end = ta.selectionEnd; const sel = ta.value.substring(start, end); const before = ta.value.substring(0, start); const after = ta.value.substring(end); if (sel) { ta.value = before + ch + sel + ch + after; ta.selectionStart = start + 1; ta.selectionEnd = end + 1; } else { ta.value = before + ch + ph + ch + after; ta.selectionStart = start + 1; ta.selectionEnd = start + 1 + ph.length; } ta.focus(); } /** * Ensure the hidden #messageText textarea has the latest editor content. */ function syncEditorToTextarea() { if (mdEditorActive && markdownEditorController) { markdownEditorController.sync(); } } /** * Apply a markdown toolbar action via the TUI editor. * @param {string} action */ function mdAction(action) { if (!tuiEditor) return; switch (action) { case 'link': showMdLinkModal(); return; default: if (markdownEditorController) { markdownEditorController.exec(action); } return; } } // Whether the picker has been loaded for this modal open let mdImagePickerLoaded = false; // File captured via paste on the Paste tab let mdImagePastedFile = null; /** * Open the insert-image modal. * The TUI editor preserves selection state internally; exec('addImage') inserts at the last cursor position. */ function showMdImageModal() { // Reset modal state document.getElementById('mdImageUrl').value = ''; document.getElementById('mdImageAltUrl').value = ''; document.getElementById('mdImageAltUpload').value = ''; document.getElementById('mdImageAltPicker').value = ''; document.getElementById('mdImageFile').value = ''; document.getElementById('mdImageUploadStatus').classList.add('d-none'); document.getElementById('mdImageModalError').classList.add('d-none'); document.getElementById('mdImageInsertBtn').disabled = false; mdImagePickerLoaded = false; // Reset paste tab state mdImagePastedFile = null; document.getElementById('mdImagePasteArea').classList.remove('paste-ready'); document.getElementById('mdImagePasteHint').classList.remove('d-none'); document.getElementById('mdImagePasteReady').classList.add('d-none'); document.getElementById('mdImagePastePreview').classList.add('d-none'); document.getElementById('mdImagePastePreview').src = ''; document.getElementById('mdImageAltPaste').value = ''; // Activate URL tab bootstrap.Tab.getOrCreateInstance(document.getElementById('mdImageUrlTab')).show(); const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('mdImageModal')); // Load picker when its tab is shown document.getElementById('mdImagePickerTab').addEventListener('shown.bs.tab', mdImageLoadPicker, { once: false }); modal.show(); } /** * Insert image markdown at saved cursor position, or upload first if the * upload tab is active. */ function mdImageInsert() { const uploadTabActive = document.getElementById('mdImageUploadPane').classList.contains('show'); const pickerTabActive = document.getElementById('mdImagePickerPane').classList.contains('show'); const pasteTabActive = document.getElementById('mdImagePastePane').classList.contains('show'); if (pasteTabActive) { if (!mdImagePastedFile) return; const alt = document.getElementById('mdImageAltPaste').value.trim() || 'image'; mdImageUpload(mdImagePastedFile, alt); return; } else if (pickerTabActive) { const sel = document.getElementById('mdImagePickerSelect'); if (!sel || !sel.value) return; const opt = sel.options[sel.selectedIndex]; const url = sel.value; const filename = opt ? opt.dataset.filename : ''; const alt = document.getElementById('mdImageAltPicker').value.trim(); mdImageDoInsert(url, alt); } else if (uploadTabActive) { const file = document.getElementById('mdImageFile').files[0]; if (!file) { mdImageShowError(window.t ? window.t('errors.markdown_images.upload_failed', {}, 'Please select a file') : 'Please select a file'); return; } mdImageUpload(file); } else { const url = document.getElementById('mdImageUrl').value.trim(); if (!url) return; const alt = document.getElementById('mdImageAltUrl').value.trim() || 'image'; mdImageDoInsert(url, alt); } } /** * Load the user's previously uploaded images into the picker dropdown. * Only fetches once per modal open. */ function mdImageLoadPicker() { if (mdImagePickerLoaded) return; mdImagePickerLoaded = true; const sel = document.getElementById('mdImagePickerSelect'); const emptyMsg = window.t ? window.t('ui.compose.markdown.image_modal.picker_empty', {}, 'No images uploaded yet.') : 'No images uploaded yet.'; $.get('/api/markdown-images') .done(function(response) { const images = (response.success && Array.isArray(response.images)) ? response.images : []; sel.innerHTML = ''; if (images.length === 0) { const opt = document.createElement('option'); opt.disabled = true; opt.textContent = emptyMsg; sel.appendChild(opt); return; } images.forEach(function(img) { const opt = document.createElement('option'); opt.value = img.url; opt.dataset.filename = img.filename; opt.textContent = img.filename; sel.appendChild(opt); }); }) .fail(function() { sel.innerHTML = ''; const opt = document.createElement('option'); opt.disabled = true; opt.textContent = emptyMsg; sel.appendChild(opt); }); } /** * Upload an image file, then insert the returned URL. * @param {File} file * @param {string} [altOverride] - If provided, use this alt text instead of reading the upload alt field */ function mdImageUpload(file, altOverride) { const btn = document.getElementById('mdImageInsertBtn'); const statusEl = document.getElementById('mdImageUploadStatus'); const progressBar = document.getElementById('mdImageUploadProgress'); const msgEl = document.getElementById('mdImageUploadMsg'); const alt = altOverride !== undefined ? altOverride : (document.getElementById('mdImageAltUpload').value.trim() || file.name.replace(/\.[^.]+$/, '')); btn.disabled = true; statusEl.classList.remove('d-none'); progressBar.style.width = '0%'; msgEl.textContent = window.t ? window.t('ui.compose.markdown.image_modal.uploading', {}, 'Uploading...') : 'Uploading...'; document.getElementById('mdImageModalError').classList.add('d-none'); const formData = new FormData(); formData.append('image', file); const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/markdown-images'); const csrfMeta = document.querySelector('meta[name="csrf-token"]'); if (csrfMeta) xhr.setRequestHeader('X-CSRF-Token', csrfMeta.content); xhr.upload.onprogress = function(e) { if (e.lengthComputable) { progressBar.style.width = Math.round((e.loaded / e.total) * 100) + '%'; } }; xhr.onload = function() { btn.disabled = false; let data; try { data = JSON.parse(xhr.responseText); } catch(e) { data = {}; } if (xhr.status === 200 && data.success) { progressBar.style.width = '100%'; bootstrap.Modal.getOrCreateInstance(document.getElementById('mdImageModal')).hide(); mdImageDoInsert(data.url, alt); } else { statusEl.classList.add('d-none'); const msg = window.getApiErrorMessage ? window.getApiErrorMessage(data, window.t ? window.t('errors.markdown_images.upload_failed', {}, 'Upload failed') : 'Upload failed') : (data.error || 'Upload failed'); mdImageShowError(msg); } }; xhr.onerror = function() { btn.disabled = false; statusEl.classList.add('d-none'); mdImageShowError(window.t ? window.t('errors.markdown_images.upload_failed', {}, 'Upload failed') : 'Upload failed'); }; xhr.send(formData); } /** * Insert an image into the active compose surface at the current cursor position. * @param {string} url * @param {string} alt */ function mdImageDoInsert(url, alt) { if (mdEditorActive && tuiEditor) { tuiEditor.exec('addImage', { imageUrl: url, altText: alt || 'image' }); tuiEditor.focus(); } else { const ta = document.getElementById('messageText'); if (!ta) return; const start = ta.selectionStart; const end = ta.selectionEnd; ta.value = ta.value.substring(0, start) + url + ta.value.substring(end); ta.selectionStart = ta.selectionEnd = start + url.length; ta.focus(); } bootstrap.Modal.getOrCreateInstance(document.getElementById('mdImageModal')).hide(); } /** * Show an error message inside the image modal. * @param {string} msg */ function mdImageShowError(msg) { const el = document.getElementById('mdImageModalError'); el.textContent = msg; el.classList.remove('d-none'); } /** * Global paste handler for the image modal's Paste tab. * Captures image data from the clipboard when the Paste pane is active. */ document.addEventListener('paste', function(e) { const pastePane = document.getElementById('mdImagePastePane'); if (!pastePane || !pastePane.classList.contains('show')) return; const items = (e.clipboardData || e.originalEvent && e.originalEvent.clipboardData || {}).items; if (!items) return; let imageFile = null; for (let i = 0; i < items.length; i++) { if (items[i].type.indexOf('image') !== -1) { imageFile = items[i].getAsFile(); break; } } if (!imageFile) { const noImageMsg = window.t ? window.t('ui.compose.markdown.image_modal.paste_no_image', {}, 'No image found in clipboard. Copy an image first, then paste.') : 'No image found in clipboard. Copy an image first, then paste.'; mdImageShowError(noImageMsg); return; } e.preventDefault(); document.getElementById('mdImageModalError').classList.add('d-none'); mdImagePastedFile = imageFile; const preview = document.getElementById('mdImagePastePreview'); const objectUrl = URL.createObjectURL(imageFile); preview.src = objectUrl; preview.classList.remove('d-none'); preview.onload = function() { URL.revokeObjectURL(objectUrl); }; document.getElementById('mdImagePasteHint').classList.add('d-none'); document.getElementById('mdImagePasteReady').classList.remove('d-none'); document.getElementById('mdImagePasteArea').classList.add('paste-ready'); }); /** * Hard-wrap keydown handler. * When the cursor is at or past hardWrapLimit on its line and the user types a * printable character, the line is broken at the last space (word wrap) or at * the limit column (hard break if no space is available). */ function initHardWrap() { const textarea = document.getElementById('messageText'); if (!textarea) return; textarea.addEventListener('keydown', function(e) { if (!hardWrapLimit) return; // Ignore non-printable keys and modified keys (Ctrl/Meta/Alt combos) if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey) return; const val = this.value; const pos = this.selectionStart; // Only wrap when cursor is at a collapsed selection (not selecting a range) if (this.selectionStart !== this.selectionEnd) return; const lineStart = val.lastIndexOf('\n', pos - 1) + 1; const currentLineLength = pos - lineStart; if (currentLineLength < hardWrapLimit) return; e.preventDefault(); if (e.key === ' ') { // Insert newline in place of the space this.value = val.substring(0, pos) + '\n' + val.substring(pos); this.selectionStart = this.selectionEnd = pos + 1; } else { // Find last space on current line to word-wrap at const lineText = val.substring(lineStart, pos); const lastSpaceRel = lineText.lastIndexOf(' '); if (lastSpaceRel >= 0) { // Replace the space with \n and append the new character after it const breakPos = lineStart + lastSpaceRel; this.value = val.substring(0, breakPos) + '\n' + val.substring(breakPos + 1, pos) + e.key + val.substring(pos); // Cursor ends up just after the newly typed character this.selectionStart = this.selectionEnd = pos + 1; } else { // No space found ? hard break at cursor, then type the character this.value = val.substring(0, pos) + '\n' + e.key + val.substring(pos); this.selectionStart = this.selectionEnd = pos + 2; } } // Keep byte counter and other input listeners in sync $(this).trigger('input'); }); } function showMarkdownHelp() { $('#markdownHelpModal').modal('show'); } // ?? Link Insert Modal ????????????????????????????????????????????????????????? /** * Open the link-insert modal, pre-filling the text field with any current * DOM selection (works in WYSIWYG mode via window.getSelection). */ function showMdLinkModal() { const selectedText = window.getSelection ? window.getSelection().toString().trim() : ''; document.getElementById('mdLinkUrl').value = ''; document.getElementById('mdLinkText').value = selectedText; const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('mdLinkModal')); // Focus URL field after shown document.getElementById('mdLinkModal').addEventListener('shown.bs.modal', function() { document.getElementById('mdLinkUrl').focus(); }, { once: true }); modal.show(); } (function() { function doInsertLink() { const url = (document.getElementById('mdLinkUrl').value || '').trim(); const text = (document.getElementById('mdLinkText').value || '').trim(); if (!url || !tuiEditor) return; tuiEditor.exec('addLink', { linkUrl: url, linkText: text || url }); tuiEditor.focus(); bootstrap.Modal.getOrCreateInstance(document.getElementById('mdLinkModal')).hide(); } document.addEventListener('DOMContentLoaded', function() { const btn = document.getElementById('mdLinkInsertBtn'); if (btn) { btn.addEventListener('click', doInsertLink); } const urlInput = document.getElementById('mdLinkUrl'); if (urlInput) { urlInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); doInsertLink(); } }); } }); })(); // ?? URL Unfurl Prompt ????????????????????????????????????????????????????????? let pendingUnfurlUrl = null; let unfurlData = null; let unfurlHandled = false; /** * Show the unfurl prompt modal for a URL that was just pasted. * The URL has already been inserted into the editor by the browser paste. */ function showUnfurlPrompt() { if (!pendingUnfurlUrl) return; unfurlData = null; unfurlHandled = false; document.getElementById('unfurlUrlDisplay').textContent = pendingUnfurlUrl; document.getElementById('unfurlLoading').classList.add('d-none'); document.getElementById('unfurlPreview').classList.add('d-none'); document.getElementById('unfurlError').classList.add('d-none'); document.getElementById('unfurlFetchBtn').classList.remove('d-none'); document.getElementById('unfurlInsertBtn').classList.add('d-none'); bootstrap.Modal.getOrCreateInstance(document.getElementById('urlUnfurlModal')).show(); } /** * Fetch OG/meta preview for the pending URL and display the preview card. */ function fetchUnfurlPreview() { if (!pendingUnfurlUrl) return; const fetchBtn = document.getElementById('unfurlFetchBtn'); const loadingEl = document.getElementById('unfurlLoading'); const errorEl = document.getElementById('unfurlError'); fetchBtn.classList.add('d-none'); errorEl.classList.add('d-none'); loadingEl.classList.remove('d-none'); $.get('/api/url-preview', { url: pendingUnfurlUrl }) .done(function(data) { loadingEl.classList.add('d-none'); if (data.success) { unfurlData = data; document.getElementById('unfurlTitle').textContent = data.title || pendingUnfurlUrl; document.getElementById('unfurlDesc').textContent = data.description || ''; const thumbCol = document.getElementById('unfurlThumbCol'); const infoCol = document.getElementById('unfurlInfoCol'); if (data.image) { document.getElementById('unfurlThumb').src = data.image; thumbCol.style.display = ''; infoCol.className = 'col-9'; } else { thumbCol.style.display = 'none'; infoCol.className = 'col'; } document.getElementById('unfurlPreview').classList.remove('d-none'); document.getElementById('unfurlInsertBtn').classList.remove('d-none'); } else { const msg = window.getApiErrorMessage ? window.getApiErrorMessage(data, uiT('errors.url_preview.fetch_failed', 'Could not fetch a preview for that URL.')) : (data.error || uiT('errors.url_preview.fetch_failed', 'Could not fetch a preview for that URL.')); errorEl.textContent = msg; errorEl.classList.remove('d-none'); fetchBtn.classList.remove('d-none'); } }) .fail(function() { loadingEl.classList.add('d-none'); errorEl.textContent = uiT('errors.url_preview.fetch_failed', 'Could not fetch a preview for that URL.'); errorEl.classList.remove('d-none'); fetchBtn.classList.remove('d-none'); }); } /** * Replace the bare pasted URL in the editor with a rich unfurled markdown block. */ function insertUnfurledPreview() { if (!tuiEditor || !unfurlData || !pendingUnfurlUrl) return; const title = (unfurlData.title || pendingUnfurlUrl).replace(/\[|\]/g, ''); const url = pendingUnfurlUrl; const desc = unfurlData.description || ''; const image = unfurlData.image || ''; // Replace the raw URL that the browser inserted during paste const currentMd = sanitizeTuiMarkdown(tuiEditor.getMarkdown()); let richMd = ''; if (image) { richMd += '[![' + title + '](' + image + ')](' + url + ')\n\n'; } richMd += '**[' + title + '](' + url + ')**'; if (desc) { richMd += '\n> ' + desc.substring(0, 200); } // Try to swap the bare URL; also handle TUI WYSIWYG auto-linking [url](url); // fall back to appending if neither form is found. let replaced = currentMd.replace(url, richMd); if (replaced === currentMd) { replaced = currentMd.replace('[' + url + '](' + url + ')', richMd); } tuiEditor.setMarkdown(replaced !== currentMd ? replaced : currentMd + '\n\n' + richMd, false); unfurlHandled = true; bootstrap.Modal.getOrCreateInstance(document.getElementById('urlUnfurlModal')).hide(); tuiEditor.focus(); } (function() { document.addEventListener('DOMContentLoaded', function() { const unfurlModal = document.getElementById('urlUnfurlModal'); if (!unfurlModal) return; document.getElementById('unfurlFetchBtn').addEventListener('click', fetchUnfurlPreview); document.getElementById('unfurlInsertBtn').addEventListener('click', insertUnfurledPreview); // "Keep as URL" ? URL was already inserted by the browser; just close document.getElementById('unfurlKeepBtn').addEventListener('click', function() { unfurlHandled = true; bootstrap.Modal.getOrCreateInstance(unfurlModal).hide(); }); // If modal is dismissed without action, URL stays as-is (already inserted) unfurlModal.addEventListener('hidden.bs.modal', function() { pendingUnfurlUrl = null; unfurlData = null; }); }); })(); $(document).ready(function() { const messageType = $('#messageType').val(); const replyToId = $('#replyToId').val(); // Font settings will be applied automatically by global settings // Check if we're loading a draft const urlParams = new URLSearchParams(window.location.search); const draftId = urlParams.get('draft'); if (draftId) { // Load draft data first, then initialize other components loadDraft(draftId).then(function() { // Initialize echo areas after draft is loaded (for echomail) if (messageType === 'echomail') { loadEchoareas(); } }); } else { // Load echo areas for echomail (normal compose) if (messageType === 'echomail') { loadEchoareas(); } } // Form submit handler $('#composeForm').on('submit', function(e) { e.preventDefault(); sendMessage(); }); // Markup type selector ? show/hide editors based on selection $('#markupType').on('change', function() { const val = $(this).val(); if (val === 'markdown') { if (scToolbarActive) destroyStyleCodesToolbar(); initMarkdownEditor(); } else if (val === 'stylecodes') { if (mdEditorActive) destroyMarkdownEditor(); initStyleCodesToolbar(); } else { if (mdEditorActive) destroyMarkdownEditor(); if (scToolbarActive) destroyStyleCodesToolbar(); } }); // Advanced Options panel ? restore and persist open/closed state and hard wrap setting loadUserSettings().then(function(settings) { if (settings.compose_advanced_open) { $('#advancedOptionsPanel').addClass('show'); $('#advancedOptionsToggle').attr('aria-expanded', 'true'); } // Restore hard wrap preference (default 79 if not set) const savedWrap = settings.compose_hard_wrap; if (savedWrap !== undefined && savedWrap !== null) { hardWrapLimit = parseInt(savedWrap, 10) || 0; $('#hardWrapSelect').val(String(hardWrapLimit)); } }); $('#advancedOptionsPanel').on('shown.bs.collapse', function() { saveUserSetting('compose_advanced_open', true); }).on('hidden.bs.collapse', function() { saveUserSetting('compose_advanced_open', false); }); // Hard wrap selector ? update limit and persist preference $('#hardWrapSelect').on('change', function() { hardWrapLimit = parseInt($(this).val(), 10) || 0; saveUserSetting('compose_hard_wrap', hardWrapLimit); }); // Initialise hard wrap keydown handler initHardWrap(); // Real-time message size warning ? show/hide #sendStatus as user types/pastes $('#messageText').on('input', function() { const bytes = getMessageByteCount(); if (bytes > MESSAGE_MAX_BYTES) { const kb = (bytes / 1024).toFixed(1); const msg = window.t('ui.compose.body_too_large', { kb }, `Message body is ${kb} KB and exceeds the 16 KB FidoNet limit.`); $('#sendStatus').show().html(`<div class="alert alert-danger mb-0"><i class="fas fa-exclamation-triangle me-2"></i>${escapeHtml(msg)}</div>`); } else if (bytes > MESSAGE_WARN_BYTES) { const kb = (bytes / 1024).toFixed(1); const msg = window.t('ui.compose.body_size_warning_inline', { kb }, `Message body is ${kb} KB ? approaching the 16 KB FidoNet limit.`); $('#sendStatus').show().html(`<div class="alert alert-warning mb-0"><i class="fas fa-exclamation-triangle me-2"></i>${escapeHtml(msg)}</div>`); } else { $('#sendStatus').hide().html(''); } }); // Address validation for netmail if (messageType === 'netmail') { $('#toAddress').on('blur', function() { validateFidonetAddressField(this); }); $('#toAddress').on('input', function() { if (markdownSupportTimer) { clearTimeout(markdownSupportTimer); } markdownSupportTimer = setTimeout(refreshMarkdownSupportForNetmail, 300); }); // Show attachment section for netmail $('#attachmentSection').show(); // Handle file selection $('#attachmentFile').on('change', function() { const file = this.files[0]; if (file) { // Set subject to filename and lock it $('#subject').val(file.name).prop('readonly', true); // Force crashmail on for remote delivery; local delivery doesn't need it updateCrashmailForAttachment(); const size = file.size < 1048576 ? (file.size / 1024).toFixed(1) + ' KB' : (file.size / 1048576).toFixed(1) + ' MB'; $('#attachmentName').text('Attached: ' + file.name + ' (' + size + ')'); } else { // Restore subject and re-enable crashmail $('#subject').prop('readonly', false); $('#crashmail').prop('disabled', false); $('#attachmentName').text(''); } }); refreshMarkdownSupportForNetmail(); } // Auto-save draft every 2 minutes setInterval(function () { saveDraft(true); // Silent save }, 120000); }); function populateEchoareaSelect(areas, preselect) { const select = $('#echoarea'); select.empty().append('<option value="">' + uiT('ui.compose.select_echo_area', 'Select echo area...') + '</option>'); allEchoareas = areas; areas.forEach(function(area) { const val = area.domain ? area.tag + '@' + area.domain : area.tag; select.append(`<option value="${val}" data-color="${area.color || '#28a745'}" data-is-local="${area.is_local ? '1' : ''}">${val}</option>`); }); select.on('change', function() { const color = $(this).find('option:selected').data('color'); if (color) $(this).css('border-left', `4px solid ${color}`); populateCrossPostAreas(); refreshMarkdownSupportForEchomail(); updateEchomailGuidelinesPostingName(); // Update charset selector based on area type and per-uplink override if (!replyCharset) { const isLocal = !!$(this).find('option:selected').data('is-local'); const val = $(this).val() || ''; const atPos = val.indexOf('@'); const domain = atPos !== -1 ? val.substring(atPos + 1).toLowerCase() : ''; let charset; if (isLocal) { charset = 'UTF-8'; } else { charset = (domain && domainCharsets[domain]) ? domainCharsets[domain] : defaultCharset; } const sel = document.getElementById('charsetSelect'); if (sel) { sel.value = charset; if (!sel.value) sel.value = defaultCharset; } } }); if (preselect) { select.val(preselect); select.trigger('change'); } else { updateEchomailGuidelinesPostingName(); } if (window.draftEchoarea) delete window.draftEchoarea; const replyToId = $('#replyToId').val(); if (!replyToId && allEchoareas.length > 1) { $('#crossPostSection').show(); populateCrossPostAreas(); } // Restore draft meta fields that depend on echoarea selection completing. // This runs after all synchronous calls to populateCrossPostAreas() and after // the echoarea change handler (which may override charset) has finished. // window.draftMeta is kept alive here so the async updateMarkdownSection() // callback can still read markup_type after fetchMarkdownSupport resolves. if (window.draftMeta) { const meta = window.draftMeta; // Re-apply charset ? the echoarea change handler may have overridden it. if (meta.charset) $('#charsetSelect').val(meta.charset); // Restore cross-post selections. if (Array.isArray(meta.cross_post_areas) && meta.cross_post_areas.length > 0) { meta.cross_post_areas.forEach(function(area) { $('#crossPostList .cross-post-check[value="' + area + '"]').prop('checked', true); }); enforceCrossPostLimit(); } // markup_type is restored asynchronously inside updateMarkdownSection() // once fetchMarkdownSupport() resolves and the section becomes visible. } } function loadEchoareas() { const urlParams = new URLSearchParams(window.location.search); const urlEchoarea = urlParams.get('echoarea'); const urlDomain = urlParams.get('domain'); let preselect = '{{ echoarea }}' || window.draftEchoarea; if (urlEchoarea) { preselect = (urlDomain && !urlEchoarea.includes('@')) ? urlEchoarea + '@' + urlDomain : urlEchoarea; } if (interestEchoareas && interestEchoareas.length > 0) { // Interest context: populate with only this interest's areas but don't auto-select one populateEchoareaSelect(interestEchoareas, preselect); return; } $.get('/api/echoareas') .done(function(data) { if (data.echoareas) { populateEchoareaSelect(data.echoareas, preselect); } }); } function populateCrossPostAreas() { const primaryVal = $('#echoarea').val(); const container = $('#crossPostList'); container.empty(); allEchoareas.forEach(function(area) { const val = area.domain ? area.tag + '@' + area.domain : area.tag; if (val === primaryVal) return; const id = 'crosspost_' + val.replace(/[@.]/g, '_'); const areaLabel = area.domain ? area.tag + '@' + area.domain : area.tag; const colorDot = `<span style="display:inline-block; width:10px; height:10px; border-radius:50%; background-color:${area.color || '#28a745'}; margin-right:6px; vertical-align:middle;"></span>`; container.append( `<div class="form-check"> <input class="form-check-input cross-post-check" type="checkbox" value="${escapeHtml(val)}" id="${id}"> <label class="form-check-label small" for="${id}">${colorDot}${escapeHtml(areaLabel)}</label> </div>` ); }); // Enforce max limit and update badge container.off('change', '.cross-post-check').on('change', '.cross-post-check', function() { enforceCrossPostLimit(); }); enforceCrossPostLimit(); } function enforceCrossPostLimit() { const checked = $('.cross-post-check:checked').length; const badge = $('#crossPostBadge'); if (checked > 0) { badge.text(checked + '/' + maxCrossPostAreas).show(); } else { badge.hide(); } if (checked >= maxCrossPostAreas) { $('.cross-post-check:not(:checked)').prop('disabled', true); } else { $('.cross-post-check').prop('disabled', false); } } function validateFidonetAddressField(field) { const address = $(field).val().trim(); if (address && !validateFidonetAddress(address)) { $(field).addClass('is-invalid'); if (!$(field).next('.invalid-feedback').length) { $(field).after(`<div class="invalid-feedback">${uiT('ui.compose.invalid_fidonet_address_format', 'Invalid Fidonet address format (e.g., 1:123/456)')}</div>`); } return false; } else { $(field).removeClass('is-invalid'); $(field).next('.invalid-feedback').remove(); return true; } } function sendMessage() { const messageType = $('#messageType').val(); const form = $('#composeForm'); // Sync markdown editor content to hidden textarea if active syncEditorToTextarea(); // Enforce 16 KB message body limit; warn when approaching (check before // form validity so the error is always visible regardless of other fields) const msgBytes = getMessageByteCount(); if (msgBytes > MESSAGE_MAX_BYTES) { const kb = (msgBytes / 1024).toFixed(1); const tooLargeMsg = window.t('ui.compose.body_too_large', { kb }, `Message body is ${kb} KB and exceeds the 16 KB FidoNet limit.`); $('#sendStatus').show().html(`<div class="alert alert-danger"><i class="fas fa-exclamation-triangle me-2"></i>${escapeHtml(tooLargeMsg)}</div>`); messageSizeWarnAcknowledged = false; return; } if (msgBytes > MESSAGE_WARN_BYTES && !messageSizeWarnAcknowledged) { const kb = (msgBytes / 1024).toFixed(1); const warnMsg = window.t('ui.compose.body_size_warning', { kb }, `Message body is ${kb} KB ? approaching the 16 KB FidoNet limit. Click Send again to proceed.`); $('#sendStatus').show().html(`<div class="alert alert-warning"><i class="fas fa-exclamation-triangle me-2"></i>${escapeHtml(warnMsg)}</div>`); messageSizeWarnAcknowledged = true; return; } messageSizeWarnAcknowledged = false; // Validate form if (!form[0].checkValidity()) { form[0].reportValidity(); return; } // Additional validation for netmail if (messageType === 'netmail') { if (!validateFidonetAddressField('#toAddress')) { return; } } // Collect cross-post areas const crossPostAreas = []; $('.cross-post-check:checked').each(function() { crossPostAreas.push($(this).val()); }); const formData = { type: messageType, to_address: $('#toAddress').val(), to_name: $('#toName').val(), echoarea: $('#echoarea').val(), subject: $('#subject').val(), message_text: $('#messageText').val(), reply_to_id: $('#replyToId').val() || null, crashmail: $('#crashmail').is(':checked'), is_freq: $('#freqCheckbox').is(':checked'), tagline: $('#taglineSelect').val(), cross_post_areas: crossPostAreas, markup_type: $('#markupType').length ? ($('#markupType').val() || null) : null, charset: $('#charsetSelect').val() || 'UTF-8' }; // Show sending status $('#sendStatus').show().html(` <div class="alert alert-info"> <i class="fas fa-spinner fa-spin me-2"></i> ${uiT('ui.compose.sending', 'Sending...')} </div> `); // Disable form form.find('input, textarea, select, button').prop('disabled', true); // Upload attachment first if one is selected (netmail only) const attachFile = document.getElementById('attachmentFile'); const hasAttachment = messageType === 'netmail' && attachFile && attachFile.files.length > 0; function doSend(attachmentToken) { if (attachmentToken) { formData.attachment_token = attachmentToken; } $.ajax({ url: `/api/messages/send`, method: 'POST', contentType: 'application/json', data: JSON.stringify(formData), success: function(response) { const areasPosted = response.areas_posted || 1; const baseSuccessMessage = window.getApiMessage ? window.getApiMessage(response, uiT('ui.api.messages.sent', 'Message sent successfully')) : uiT('ui.api.messages.sent', 'Message sent successfully'); const crossPostMsg = areasPosted > 1 ? uiT('ui.compose.sent_cross_post_suffix', ' (posted to {count} areas)', { count: areasPosted }) : ''; $('#sendStatus').html(` <div class="alert alert-success"> <i class="fas fa-check me-2"></i> ${baseSuccessMessage}${crossPostMsg} </div> `); // Delete draft after successful send if we loaded from a draft if (window.currentDraftId) { deleteDraftAfterSend(window.currentDraftId); } setTimeout(function() { let returnUrl = `/${messageType}`; const explicitReturnTo = ($('#returnTo').val() || '').trim(); if (explicitReturnTo.startsWith('/')) { returnUrl = explicitReturnTo; } else if (messageType === 'echomail') { const areaVal = $('#echoarea').val() || ''; if (areaVal) returnUrl = `/echomail/${encodeURIComponent(areaVal)}`; } window.location.href = returnUrl; }, 2000); }, error: function(xhr) { const sendErrorFallback = uiT('ui.compose.send_failed_type', 'Failed to send {type}', { type: messageType }); const error = window.getApiErrorMessage ? window.getApiErrorMessage(xhr.responseJSON, sendErrorFallback) : sendErrorFallback; $('#sendStatus').html(` <div class="alert alert-danger"> <i class="fas fa-exclamation-triangle me-2"></i> ${escapeHtml(error)} </div> `); // Re-enable form form.find('input, textarea, select, button').prop('disabled', false); } }); } // end doSend if (hasAttachment) { // Upload the file first, then send the message with the returned token const uploadData = new FormData(); uploadData.append('file', attachFile.files[0]); $.ajax({ url: '/api/netmail/attachment/upload', method: 'POST', data: uploadData, processData: false, contentType: false, success: function(response) { doSend(response.token); }, error: function(xhr) { const uploadErrorFallback = uiT('ui.compose.upload_attachment_failed', 'Failed to upload attachment'); const error = window.getApiErrorMessage ? window.getApiErrorMessage(xhr.responseJSON, uploadErrorFallback) : uploadErrorFallback; $('#sendStatus').html(` <div class="alert alert-danger"> <i class="fas fa-exclamation-triangle me-2"></i> ${escapeHtml(error)} </div> `); form.find('input, textarea, select, button').prop('disabled', false); } }); } else { doSend(null); } } function saveDraft(silent = false) { syncEditorToTextarea(); // Collect cross-post areas for echomail drafts const crossPostAreas = []; $('.cross-post-check:checked').each(function() { crossPostAreas.push($(this).val()); }); const formData = { type: $('#messageType').val(), to_address: $('#toAddress').val(), to_name: $('#toName').val(), echoarea: $('#echoarea').val(), subject: $('#subject').val(), message_text: $('#messageText').val(), reply_to_id: $('#replyToId').val() || null, meta: { cross_post_areas: crossPostAreas, charset: $('#charsetSelect').val() || null, markup_type: $('#markupType').val() || null, hard_wrap: hardWrapLimit, tagline: $('#taglineSelect').val() || null } }; // Only save if there's actual content if (!formData.subject.trim() && !formData.message_text.trim()) { if (!silent) { showError(uiT('ui.compose.draft.empty_content', 'Please add some content before saving draft')); } return; } // Get the save draft button and show loading state (only if not silent) const saveButton = $('button[onclick="saveDraft()"]'); const originalButtonHtml = saveButton.html(); if (!silent && saveButton.length) { saveButton.prop('disabled', true).html(`<i class="fas fa-spinner fa-spin"></i> ${uiT('ui.common.saving', 'Saving...')}`); } $.ajax({ url: '/api/messages/draft', method: 'POST', contentType: 'application/json', data: JSON.stringify(formData), success: function(response) { const savedMessage = window.getApiMessage ? window.getApiMessage(response, uiT('ui.compose.draft.saved_success', 'Draft saved successfully')) : uiT('ui.compose.draft.saved_success', 'Draft saved successfully'); if (!silent) { // Show success state on button if (saveButton.length) { saveButton.removeClass('btn-outline-primary').addClass('btn-success') .html(`<i class="fas fa-check"></i> ${uiT('ui.common.saved', 'Saved!')}`); // Reset button after 2 seconds setTimeout(function() { saveButton.addClass('btn-outline-primary').removeClass('btn-success') .prop('disabled', false).html(originalButtonHtml); }, 2000); } else { showSuccess(savedMessage); } } else { // For silent saves, just reset button if it was showing loading if (saveButton.length && saveButton.prop('disabled')) { saveButton.prop('disabled', false).html(originalButtonHtml); } } }, error: function() { if (!silent) { // Reset button on error if (saveButton.length) { saveButton.removeClass('btn-success').addClass('btn-outline-primary') .prop('disabled', false).html(originalButtonHtml); } showError(uiT('errors.messages.drafts.save_failed', 'Failed to save draft')); } } }); } function loadDraft(draftId) { return new Promise(function(resolve, reject) { $.ajax({ url: `/api/messages/drafts/${draftId}`, method: 'GET', success: function(response) { if (response.success && response.draft) { const draft = response.draft; // Populate form fields with draft data if (draft.to_address) $('#toAddress').val(draft.to_address); if (draft.to_name) $('#toName').val(draft.to_name); if (draft.subject) $('#subject').val(draft.subject); if (draft.message_text) $('#messageText').val(draft.message_text); if (draft.echoarea) { // For echomail, the echoarea will be set after loadEchoareas() completes window.draftEchoarea = draft.echoarea; } if (draft.reply_to_id) $('#replyToId').val(draft.reply_to_id); // Preserve meta so populateEchoareaSelect() and // updateMarkdownSection() can restore echomail-dependent fields // after the area selection fires its async callbacks. if (draft.meta) { window.draftMeta = draft.meta; // Fields not overridden by echoarea selection: restore immediately. if (draft.meta.tagline !== undefined && draft.meta.tagline !== null) { $('#taglineSelect').val(draft.meta.tagline); } if (draft.meta.hard_wrap !== undefined && draft.meta.hard_wrap !== null) { hardWrapLimit = parseInt(draft.meta.hard_wrap, 10) || 0; $('#hardWrapSelect').val(String(hardWrapLimit)); } // Charset: apply now for netmail (no echoarea override). // populateEchoareaSelect() will re-apply for echomail after // the area change handler overwrites it. if (draft.meta.charset) $('#charsetSelect').val(draft.meta.charset); } // Store draft ID for potential deletion after sending window.currentDraftId = draftId; console.log('Draft loaded successfully:', draft); resolve(draft); } else { showError(uiT('errors.messages.drafts.get_failed', 'Failed to load draft')); reject('Draft not found'); } }, error: function() { showError(uiT('errors.messages.drafts.get_failed', 'Failed to load draft')); reject('API error'); } }); }); } function deleteDraftAfterSend(draftId) { $.ajax({ url: `/api/messages/drafts/${draftId}`, method: 'DELETE', success: function(response) { console.log('Draft deleted successfully after sending message'); }, error: function() { console.warn('Failed to delete draft after sending message'); // Don't show error to user since message was sent successfully } }); } // Address Book Functions for Compose Page function loadComposeAddressBook(search = '') { $.get('/api/address-book', { search: search }) .done(function(response) { if (response.success) { renderComposeAddressBook(response.entries); } else { $('#composeAddressList').html(`<li><div class="text-danger py-2">${uiT('ui.compose.address_book.load_failed_short', 'Failed to load')}</div></li>`); } }) .fail(function(xhr, status, error) { $('#composeAddressList').html(`<li><div class="text-danger py-2">${uiT('ui.compose.address_book.load_failed_short', 'Failed to load')}</div></li>`); }); } function renderComposeAddressBook(entries) { const container = $('#composeAddressList'); let html = ''; if (entries.length === 0) { html = `<li><div class="text-center text-muted py-2">${uiT('ui.address_book.no_entries_found', 'No entries found')}</div></li>`; } else { entries.forEach(function(entry) { html += ` <li><a class="dropdown-item" href="#" onclick="selectAddressBookEntry('${escapeHtml(entry.messaging_user_id || uiT('ui.common.unknown', 'Unknown'))}', '${escapeHtml(entry.node_address || '')}', ${entry.always_crashmail ? 'true' : 'false'})"> <div class="fw-bold">${escapeHtml(entry.name || uiT('ui.address_book.unnamed', 'Unnamed'))}</div> <div class="text-primary small">@${escapeHtml(entry.messaging_user_id || uiT('ui.common.unknown', 'Unknown'))}</div> <div class="text-muted small font-monospace">${escapeHtml(entry.node_address || uiT('ui.address_book.no_address', 'No address'))}</div> ${entry.description ? `<div class="text-muted smaller">${escapeHtml(entry.description.substring(0, 40) + (entry.description.length > 40 ? '...' : ''))}</div>` : ''} </a></li> `; }); } container.html(html); } function searchAddressBookForCompose(query) { if (query.length > 0) { loadComposeAddressBook(query); } else { loadComposeAddressBook(); } } function selectAddressBookEntry(fullName, nodeAddress, alwaysCrashmail) { $('#toName').val(fullName); $('#toAddress').val(nodeAddress); // Set crashmail checkbox to match the contact's preference if ($('#crashmail').length) { $('#crashmail').prop('checked', alwaysCrashmail); } refreshMarkdownSupportForNetmail(); // Close dropdown const dropdown = bootstrap.Dropdown.getInstance($('#addressBookButton')); if (dropdown) { dropdown.hide(); } // Clear search $('#composeAddressSearch').val(''); } // Initialize address book when dropdown is shown $(document).ready(function() { $('#addressBookDropdown').on('show.bs.dropdown', function() { // Only load if not already loaded if ($('#composeAddressList').find('.fa-spinner').length > 0) { loadComposeAddressBook(); } }); // Pre-load address book entries on page load for faster access loadComposeAddressBook(); }); </script> <!-- Address Book Entry Modal (only for netmail) --> {% if type == 'netmail' %} <div class="modal fade" id="addressBookModal" tabindex="-1"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title" id="addressBookModalTitle">{{ t('ui.address_book.add_entry', {}, locale, ['common']) }}</h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <div id="addressBookModalError" class="alert alert-danger d-none" role="alert"></div> <form id="addressBookForm"> <input type="hidden" id="addressBookEntryId"> <div class="mb-3"> <label for="addressBookName" class="form-label">{{ t('ui.common.name', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="addressBookName" required placeholder="{{ t('ui.address_book.name_placeholder', {}, locale, ['common']) }}"> <div class="form-text">{{ t('ui.address_book.name_help', {}, locale, ['common']) }}</div> </div> <div class="mb-3"> <label for="addressBookUserId" class="form-label">{{ t('ui.address_book.user_id', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="addressBookUserId" required placeholder="{{ t('ui.address_book.user_id_placeholder', {}, locale, ['common']) }}"> <div class="form-text">{{ t('ui.address_book.user_id_help', {}, locale, ['common']) }}</div> </div> <div class="mb-3"> <label for="addressBookNodeAddress" class="form-label">{{ t('ui.address_book.node_address', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="addressBookNodeAddress" required placeholder="{{ t('ui.address_book.node_address_placeholder', {}, locale, ['common']) }}" pattern="^\d+:\d+\/\d+(\.\d+)?$" title="{{ t('ui.address_book.node_address_title', {}, locale, ['common']) }}"> <div class="form-text">{{ t('ui.address_book.node_address_help', {}, locale, ['common']) }}</div> </div> <div class="mb-3"> <label for="addressBookEmail" class="form-label">{{ t('ui.address_book.email_address', {}, locale, ['common']) }} <small class="text-muted">({{ t('ui.common.optional', {}, locale, ['common']) }})</small></label> <input type="email" class="form-control" id="addressBookEmail" placeholder="email@example.com"> <div class="form-text">{{ t('ui.address_book.email_help', {}, locale, ['common']) }}</div> </div> <div class="mb-3"> <label for="addressBookDescription" class="form-label">{{ t('ui.common.description', {}, locale, ['common']) }} <small class="text-muted">({{ t('ui.common.optional', {}, locale, ['common']) }})</small></label> <textarea class="form-control" id="addressBookDescription" rows="3" placeholder="{{ t('ui.address_book.description_placeholder', {}, locale, ['common']) }}"></textarea> </div> {% if crashmail_enabled %} <div class="mb-3"> <div class="form-check"> <input type="checkbox" class="form-check-input" id="addressBookAlwaysCrashmail" name="always_crashmail"> <label class="form-check-label" for="addressBookAlwaysCrashmail"> <i class="fas fa-bolt text-warning me-1"></i>{{ t('ui.address_book.always_crashmail', {}, locale, ['common']) }} </label> <div class="form-text">{{ t('ui.address_book.always_crashmail_help', {}, locale, ['common']) }}</div> </div> </div> {% endif %} </form> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.cancel', {}, locale, ['common']) }}</button> <button type="button" class="btn btn-primary" onclick="saveComposeAddressBookEntry()"> <i class="fas fa-save"></i> {{ t('ui.common.save', {}, locale, ['common']) }} </button> </div> </div> </div> </div> <script> // Address book modal functions for compose page function showAddAddressModal() { $('#addressBookModalTitle').text(uiT('ui.address_book.add_entry', 'Add Address Book Entry')); $('#addressBookEntryId').val(''); $('#addressBookForm')[0].reset(); clearAddressBookModalError(); $('#addressBookModal').modal('show'); } function showAddressBookModalError(message) { const fallback = uiT('errors.address_book.create_failed', 'Failed to save entry'); const text = (typeof message === 'string' && message.trim() !== '') ? message : fallback; const errorEl = $('#addressBookModalError'); if (errorEl.length) { errorEl.text(text).removeClass('d-none'); } showError(text); } function clearAddressBookModalError() { const errorEl = $('#addressBookModalError'); if (errorEl.length) { errorEl.addClass('d-none').text(''); } } function saveComposeAddressBookEntry() { clearAddressBookModalError(); const data = { name: $('#addressBookName').val().trim(), messaging_user_id: $('#addressBookUserId').val().trim(), node_address: $('#addressBookNodeAddress').val().trim(), email: $('#addressBookEmail').val().trim(), description: $('#addressBookDescription').val().trim(), always_crashmail: $('#addressBookAlwaysCrashmail').is(':checked'), }; // Basic validation if (!data.name || !data.messaging_user_id || !data.node_address) { showAddressBookModalError(uiT('errors.address_book.required_fields', 'Name, user ID, and node address are required')); return; } $.ajax({ url: '/api/address-book/', method: 'POST', contentType: 'application/json', data: JSON.stringify(data), success: function(response) { if (response.success) { $('#addressBookModal').modal('hide'); loadComposeAddressBook(); const successMessage = window.getApiMessage ? window.getApiMessage(response, uiT('ui.compose.address_book.entry_added', 'Entry added successfully')) : uiT('ui.compose.address_book.entry_added', 'Entry added successfully'); showSuccess(successMessage); // Optionally populate the compose form if (confirm(uiT('ui.compose.address_book.use_entry_confirm', 'Use this entry for the current message?'))) { $('#toName').val(data.messaging_user_id); $('#toAddress').val(data.node_address); } } else { const error = window.getApiErrorMessage ? window.getApiErrorMessage(response, uiT('errors.address_book.create_failed', 'Failed to save entry')) : uiT('errors.address_book.create_failed', 'Failed to save entry'); showAddressBookModalError(error); } }, error: function(xhr) { const error = window.getApiErrorMessage ? window.getApiErrorMessage(xhr.responseJSON, uiT('errors.address_book.create_failed', 'Failed to save entry')) : uiT('errors.address_book.create_failed', 'Failed to save entry'); showAddressBookModalError(error); } }); } </script> {% endif %} <!-- Markdown Insert Image Modal --> <div class="modal fade" id="mdImageModal" tabindex="-1" aria-labelledby="mdImageModalLabel"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title" id="mdImageModalLabel"><i class="fas fa-image me-2"></i>{{ t('ui.compose.markdown.image_modal.title', {}, locale, ['common']) }}</h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <ul class="nav nav-tabs mb-3" id="mdImageTabs" role="tablist"> <li class="nav-item" role="presentation"> <button class="nav-link active" id="mdImageUrlTab" data-bs-toggle="tab" data-bs-target="#mdImageUrlPane" type="button" role="tab"> {{ t('ui.compose.markdown.image_modal.tab_url', {}, locale, ['common']) }} </button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="mdImageUploadTab" data-bs-toggle="tab" data-bs-target="#mdImageUploadPane" type="button" role="tab"> {{ t('ui.compose.markdown.image_modal.tab_upload', {}, locale, ['common']) }} </button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="mdImagePickerTab" data-bs-toggle="tab" data-bs-target="#mdImagePickerPane" type="button" role="tab"> {{ t('ui.compose.markdown.image_modal.tab_picker', {}, locale, ['common']) }} </button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="mdImagePasteTab" data-bs-toggle="tab" data-bs-target="#mdImagePastePane" type="button" role="tab"> {{ t('ui.compose.markdown.image_modal.tab_paste', {}, locale, ['common']) }} </button> </li> </ul> <div class="tab-content"> <div class="tab-pane fade show active" id="mdImageUrlPane" role="tabpanel"> <div class="mb-3"> <label for="mdImageUrl" class="form-label">{{ t('ui.compose.markdown.image_modal.url_label', {}, locale, ['common']) }}</label> <input type="url" class="form-control" id="mdImageUrl" placeholder="{{ t('ui.compose.markdown.image_modal.url_placeholder', {}, locale, ['common']) }}"> </div> <div class="mb-3"> <label for="mdImageAltUrl" class="form-label">{{ t('ui.compose.markdown.image_modal.alt_label', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="mdImageAltUrl" placeholder="{{ t('ui.compose.markdown.image_modal.alt_placeholder', {}, locale, ['common']) }}"> </div> </div> <div class="tab-pane fade" id="mdImageUploadPane" role="tabpanel"> <div class="mb-3"> <label for="mdImageFile" class="form-label">{{ t('ui.compose.markdown.image_modal.tab_upload', {}, locale, ['common']) }}</label> <input type="file" class="form-control" id="mdImageFile" accept="image/jpeg,image/png,image/gif,image/webp"> <div class="form-text">{{ t('ui.compose.markdown.image_modal.upload_hint', {}, locale, ['common']) }}</div> </div> <div class="mb-3"> <label for="mdImageAltUpload" class="form-label">{{ t('ui.compose.markdown.image_modal.alt_label', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="mdImageAltUpload" placeholder="{{ t('ui.compose.markdown.image_modal.alt_placeholder', {}, locale, ['common']) }}"> </div> <div id="mdImageUploadStatus" class="d-none"> <div class="progress mb-2"> <div class="progress-bar progress-bar-striped progress-bar-animated" id="mdImageUploadProgress" style="width:0%"></div> </div> <small id="mdImageUploadMsg" class="text-muted"></small> </div> </div> <div class="tab-pane fade" id="mdImagePickerPane" role="tabpanel"> <div class="mb-3"> <select class="form-select" id="mdImagePickerSelect" size="6"> <option disabled>{{ t('ui.compose.markdown.image_modal.picker_loading', {}, locale, ['common']) }}</option> </select> </div> <div class="mb-0"> <label for="mdImageAltPicker" class="form-label">{{ t('ui.compose.markdown.image_modal.alt_label', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="mdImageAltPicker" placeholder="{{ t('ui.compose.markdown.image_modal.alt_placeholder', {}, locale, ['common']) }}"> </div> </div> <div class="tab-pane fade" id="mdImagePastePane" role="tabpanel"> <div id="mdImagePasteArea" class="md-paste-area mb-3" tabindex="0"> <p id="mdImagePasteHint" class="text-muted mb-0"> <i class="fas fa-clipboard me-1"></i> {{ t('ui.compose.markdown.image_modal.paste_hint', {}, locale, ['common']) }} </p> <p id="mdImagePasteReady" class="text-success mb-1 d-none"> <i class="fas fa-check-circle me-1"></i> {{ t('ui.compose.markdown.image_modal.paste_ready', {}, locale, ['common']) }} </p> <img id="mdImagePastePreview" src="" alt="" class="img-fluid d-none mt-2" style="max-height:180px;"> </div> <div class="mb-0"> <label for="mdImageAltPaste" class="form-label">{{ t('ui.compose.markdown.image_modal.alt_label', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="mdImageAltPaste" placeholder="{{ t('ui.compose.markdown.image_modal.alt_placeholder', {}, locale, ['common']) }}"> </div> </div> </div> <div id="mdImageModalError" class="alert alert-danger d-none mt-2"></div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.cancel', {}, locale, ['common']) }}</button> <button type="button" class="btn btn-primary" id="mdImageInsertBtn" onclick="mdImageInsert()">{{ t('ui.compose.markdown.image_modal.insert', {}, locale, ['common']) }}</button> </div> </div> </div> </div> <!-- Link Insert Modal --> <div class="modal fade" id="mdLinkModal" tabindex="-1" aria-labelledby="mdLinkModalLabel"> <div class="modal-dialog modal-sm"> <div class="modal-content"> <div class="modal-header py-2"> <h6 class="modal-title" id="mdLinkModalLabel"><i class="fas fa-link me-2"></i>{{ t('ui.compose.markdown.link_modal.title', {}, locale, ['common']) }}</h6> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body py-2"> <div class="mb-2"> <label class="form-label form-label-sm mb-1" for="mdLinkUrl">{{ t('ui.compose.markdown.link_modal.url_label', {}, locale, ['common']) }}</label> <input type="url" class="form-control form-control-sm" id="mdLinkUrl" placeholder="https://"> </div> <div class="mb-0"> <label class="form-label form-label-sm mb-1" for="mdLinkText">{{ t('ui.compose.markdown.link_modal.text_label', {}, locale, ['common']) }}</label> <input type="text" class="form-control form-control-sm" id="mdLinkText" placeholder="{{ t('ui.compose.markdown.link_modal.text_placeholder', {}, locale, ['common']) }}"> </div> </div> <div class="modal-footer py-2"> <button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{{ t('ui.common.cancel', {}, locale, ['common']) }}</button> <button type="button" class="btn btn-primary btn-sm" id="mdLinkInsertBtn">{{ t('ui.compose.markdown.link_modal.insert', {}, locale, ['common']) }}</button> </div> </div> </div> </div> <!-- URL Unfurl Prompt Modal --> <div class="modal fade" id="urlUnfurlModal" tabindex="-1" aria-labelledby="urlUnfurlModalLabel"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header py-2"> <h6 class="modal-title" id="urlUnfurlModalLabel"><i class="fas fa-link me-2"></i>{{ t('ui.compose.markdown.unfurl.modal_title', {}, locale, ['common']) }}</h6> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <p class="text-muted small mb-2">{{ t('ui.compose.markdown.unfurl.description', {}, locale, ['common']) }}</p> <div id="unfurlUrlDisplay" class="font-monospace small text-break bg-light rounded px-2 py-1 mb-3"></div> <div id="unfurlLoading" class="text-center d-none py-2"> <div class="spinner-border spinner-border-sm me-2" role="status"></div>{{ t('ui.compose.markdown.unfurl.fetching', {}, locale, ['common']) }} </div> <div id="unfurlPreview" class="d-none"> <div class="card"> <div class="row g-0"> <div class="col-3" id="unfurlThumbCol" style="display:none;"> <img id="unfurlThumb" src="" class="img-fluid rounded-start" style="object-fit:cover;height:100%;max-height:100px;" alt=""> </div> <div class="col" id="unfurlInfoCol"> <div class="card-body py-2 px-3"> <h6 class="card-title mb-1 small fw-bold" id="unfurlTitle"></h6> <p class="card-text text-muted small mb-0" id="unfurlDesc"></p> </div> </div> </div> </div> </div> <div id="unfurlError" class="text-danger small d-none mt-2"></div> </div> <div class="modal-footer py-2"> <button type="button" class="btn btn-secondary btn-sm" id="unfurlKeepBtn">{{ t('ui.compose.markdown.unfurl.keep_as_url', {}, locale, ['common']) }}</button> <button type="button" class="btn btn-primary btn-sm" id="unfurlFetchBtn">{{ t('ui.compose.markdown.unfurl.fetch_preview', {}, locale, ['common']) }}</button> <button type="button" class="btn btn-success btn-sm d-none" id="unfurlInsertBtn">{{ t('ui.compose.markdown.unfurl.insert_preview', {}, locale, ['common']) }}</button> </div> </div> </div> </div> <!-- Markdown Quick Reference Modal --> <div class="modal fade" id="markdownHelpModal" tabindex="-1"> <div class="modal-dialog modal-lg"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title"><i class="fab fa-markdown me-2"></i>{{ t('ui.compose.markdown.quick_reference_title', {}, locale, ['common']) }}</h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <div class="row"> <div class="col-md-6"> <h6>{{ t('ui.compose.markdown.help.text_formatting', {}, locale, ['common']) }}</h6> <table class="table table-sm table-bordered"> <thead><tr><th>{{ t('ui.compose.markdown.help.syntax', {}, locale, ['common']) }}</th><th>{{ t('ui.compose.markdown.help.result', {}, locale, ['common']) }}</th></tr></thead> <tbody> <tr><td><code>**bold**</code></td><td><strong>bold</strong></td></tr> <tr><td><code>*italic*</code></td><td><em>italic</em></td></tr> <tr><td><code>`inline code`</code></td><td><code>inline code</code></td></tr> <tr><td><code>[link](url)</code></td><td>{{ t('ui.compose.markdown.help.hyperlink', {}, locale, ['common']) }}</td></tr> <tr><td><code>![alt](url)</code></td><td>{{ t('ui.compose.markdown.help.image', {}, locale, ['common']) }}</td></tr> </tbody> </table> <h6>{{ t('ui.compose.markdown.help.headings', {}, locale, ['common']) }}</h6> <table class="table table-sm table-bordered"> <tbody> <tr><td><code># Heading 1</code></td><td><strong>H1</strong></td></tr> <tr><td><code>## Heading 2</code></td><td><strong>H2</strong></td></tr> <tr><td><code>### Heading 3</code></td><td><strong>H3</strong></td></tr> </tbody> </table> </div> <div class="col-md-6"> <h6>{{ t('ui.compose.markdown.help.lists_blocks', {}, locale, ['common']) }}</h6> <table class="table table-sm table-bordered"> <tbody> <tr><td><code>- item</code></td><td>{{ t('ui.compose.markdown.help.bullet_list', {}, locale, ['common']) }}</td></tr> <tr><td><code>1. item</code></td><td>{{ t('ui.compose.markdown.help.numbered_list', {}, locale, ['common']) }}</td></tr> <tr><td><code>&gt; text</code></td><td>{{ t('ui.compose.markdown.help.blockquote', {}, locale, ['common']) }}</td></tr> <tr><td><code>---</code></td><td>{{ t('ui.compose.markdown.help.horizontal_rule', {}, locale, ['common']) }}</td></tr> </tbody> </table> <h6>{{ t('ui.compose.markdown.help.code_block', {}, locale, ['common']) }}</h6> <pre class="bg-light p-2 rounded small">``` code here ```</pre> <h6>{{ t('ui.compose.markdown.help.keyboard_shortcuts', {}, locale, ['common']) }}</h6> <table class="table table-sm table-bordered"> <tbody> <tr><td><kbd>Ctrl+B</kbd></td><td>{{ t('ui.compose.markdown.help.shortcut_bold', {}, locale, ['common']) }}</td></tr> <tr><td><kbd>Ctrl+I</kbd></td><td>{{ t('ui.compose.markdown.help.shortcut_italic', {}, locale, ['common']) }}</td></tr> <tr><td><kbd>Ctrl+K</kbd></td><td>{{ t('ui.compose.markdown.help.shortcut_link', {}, locale, ['common']) }}</td></tr> <tr><td><kbd>Tab</kbd></td><td>{{ t('ui.compose.markdown.help.shortcut_indent', {}, locale, ['common']) }}</td></tr> </tbody> </table> </div> </div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.close', {}, locale, ['common']) }}</button> </div> </div> </div> </div> <script> // Collapsible compose sidebar with localStorage persistence const SIDEBAR_KEY = 'compose_sidebar_hidden'; function toggleComposeSidebar() { const sidebar = document.getElementById('composeSidebar'); const main = document.getElementById('composeMain'); const showBtn = document.getElementById('sidebarShowBtn'); const hidden = !sidebar.classList.contains('d-none'); sidebar.classList.toggle('d-none', hidden); showBtn.classList.toggle('d-none', !hidden); showBtn.classList.toggle('d-flex', !hidden); main.classList.toggle('col-lg-12', hidden); main.classList.toggle('col-lg-8', !hidden); UserStorage.setItem(SIDEBAR_KEY, hidden ? '1' : '0'); } document.getElementById('sidebarToggleBtn').addEventListener('click', toggleComposeSidebar); // Restore state on load if (UserStorage.getItem(SIDEBAR_KEY) === '1') { toggleComposeSidebar(); } </script> {% if license_valid %} {# ?? Save Template Modal ??????????????????????????????????????????????? #} <div class="modal fade" id="saveTemplateModal" tabindex="-1"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title"><i class="fas fa-file-alt me-2"></i>{{ t('ui.compose.templates.save_modal_title', {}, locale, ['common']) }}</h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <div id="saveTemplateAlert" class="alert" style="display:none"></div> <div class="mb-3"> <label class="form-label" for="templateName">{{ t('ui.compose.templates.name_label', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="templateName" maxlength="100" placeholder="{{ t('ui.compose.templates.name_placeholder', {}, locale, ['common']) }}"> </div> <div class="mb-3"> <label class="form-label">{{ t('ui.compose.templates.type_label', {}, locale, ['common']) }}</label> <select class="form-select" id="templateType"> <option value="both">{{ t('ui.compose.templates.type_both', {}, locale, ['common']) }}</option> <option value="netmail">{{ t('ui.compose.templates.type_netmail', {}, locale, ['common']) }}</option> <option value="echomail">{{ t('ui.compose.templates.type_echomail', {}, locale, ['common']) }}</option> </select> </div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.cancel', {}, locale, ['common']) }}</button> <button type="button" class="btn btn-primary" onclick="saveAsTemplate()">{{ t('ui.compose.templates.save_button', {}, locale, ['common']) }}</button> </div> </div> </div> </div> {# ?? Manage Templates Modal ???????????????????????????????????????????? #} <div class="modal fade" id="manageTemplatesModal" tabindex="-1"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title"><i class="fas fa-cog me-2"></i>{{ t('ui.compose.templates.manage_modal_title', {}, locale, ['common']) }}</h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <div id="manageTemplatesAlert" class="alert" style="display:none"></div> <div id="manageTemplatesList"> <p class="text-muted">{{ t('ui.compose.templates.loading', {}, locale, ['common']) }}</p> </div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.close', {}, locale, ['common']) }}</button> </div> </div> </div> </div> <script> (function() { const messageType = $('#messageType').val(); // ?? Helpers ?????????????????????????????????????????????????????????? function showTemplateAlert(containerId, message, type) { $('#' + containerId).removeClass('alert-success alert-danger alert-warning') .addClass('alert-' + type).text(message).show(); } function syncEditorToTextarea() { // Ensure markdown editor content is flushed to the hidden textarea if (typeof syncEditorContent === 'function') syncEditorContent(); } // ?? Load templates into dropdown ????????????????????????????????????? function loadTemplatesDropdown() { $.get('/api/messages/templates', { type: messageType }) .done(function(data) { const templates = data.templates || []; $('#templatesLoadingItem').hide(); // Remove previously injected template items $('#templatesDropdownMenu .template-item').remove(); $('#templatesDivider').toggle(templates.length > 0); templates.forEach(function(tpl) { const item = $('<li class="template-item"><a class="dropdown-item" href="#"></a></li>'); item.find('a').text(tpl.name).on('click', function(e) { e.preventDefault(); loadTemplate(tpl.id); }); $('#templatesDivider').before(item); }); if (templates.length === 0) { const empty = $('<li class="template-item"><span class="dropdown-item text-muted small"></span></li>'); empty.find('span').text(window.t('ui.compose.templates.none', {}, 'No saved templates')); $('#templatesDivider').before(empty); $('#templatesDivider').hide(); } }); } // Load on dropdown open $('#templatesDropdownBtn').closest('.btn-group').on('show.bs.dropdown', loadTemplatesDropdown); // ?? Load a template into the compose form ????????????????????????????? function loadTemplate(id) { $.get('/api/messages/templates/' + id) .done(function(data) { const tpl = data.template; if (tpl.subject) $('#subject').val(tpl.subject); if (tpl.body) { $('#messageText').val(tpl.body); if (typeof setEditorContent === 'function') setEditorContent(tpl.body); } }) .fail(function() { alert(window.t('ui.compose.templates.load_failed', {}, 'Failed to load template')); }); } // ?? Save current compose as template ????????????????????????????????? window.openSaveTemplateModal = function() { $('#saveTemplateAlert').hide(); $('#templateName').val(''); $('#templateType').val(messageType === 'netmail' || messageType === 'echomail' ? messageType : 'both'); new bootstrap.Modal(document.getElementById('saveTemplateModal')).show(); }; window.saveAsTemplate = function() { syncEditorToTextarea(); const name = $('#templateName').val().trim(); if (!name) { showTemplateAlert('saveTemplateAlert', window.t('ui.compose.templates.name_required', {}, 'Please enter a template name'), 'warning'); return; } $.ajax({ url: '/api/messages/templates', method: 'POST', contentType: 'application/json', data: JSON.stringify({ name: name, type: $('#templateType').val(), subject: $('#subject').val(), body: $('#messageText').val() }) }).done(function() { bootstrap.Modal.getInstance(document.getElementById('saveTemplateModal')).hide(); }).fail(function() { showTemplateAlert('saveTemplateAlert', window.t('ui.compose.templates.save_failed', {}, 'Failed to save template'), 'danger'); }); }; // ?? Manage templates modal ???????????????????????????????????????????? window.openManageTemplatesModal = function() { $('#manageTemplatesAlert').hide(); renderManageList(); new bootstrap.Modal(document.getElementById('manageTemplatesModal')).show(); }; function renderManageList() { const list = $('#manageTemplatesList'); list.html('<p class="text-muted">' + window.t('ui.compose.templates.loading', {}, 'Loading...') + '</p>'); $.get('/api/messages/templates').done(function(data) { const templates = data.templates || []; if (templates.length === 0) { list.html('<p class="text-muted">' + window.t('ui.compose.templates.none', {}, 'No saved templates') + '</p>'); return; } const ul = $('<ul class="list-group"></ul>'); templates.forEach(function(tpl) { const li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>'); const label = $('<span></span>').text(tpl.name); const badge = $('<span class="badge bg-secondary ms-2"></span>').text(tpl.type); const del = $('<button class="btn btn-sm btn-outline-danger ms-auto"><i class="fas fa-trash"></i></button>'); del.on('click', function() { if (!confirm(window.t('ui.compose.templates.delete_confirm', {}, 'Delete this template?'))) return; $.ajax({ url: '/api/messages/templates/' + tpl.id, method: 'DELETE' }) .done(function() { renderManageList(); }) .fail(function() { showTemplateAlert('manageTemplatesAlert', window.t('ui.compose.templates.delete_failed', {}, 'Failed to delete template'), 'danger'); }); }); li.append(label).append(badge).append(del); ul.append(li); }); list.html('').append(ul); }).fail(function() { list.html('<p class="text-danger">' + window.t('ui.compose.templates.load_failed', {}, 'Failed to load templates') + '</p>'); }); } })(); </script> {% endif %} {% endblock %}