?{% 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> <em>/italic/</em> <u>_underline_</u> #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']) }}">—</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 + ' — ' +
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">— ' + 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 += '[](' + 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></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>> 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 %}
|