/** * Share Composer — the "+" button next to the message input * * Lets the user drop a contact, a channel or a position into the message they * are writing. The payload is inserted as plain text in the format the official * MeshCore app uses (see share-tokens.js), because that is the interop contract * — nothing here invents a format. * * Shared verbatim by the channel chat and the DM panel: the two composers are * separate components with different element ids, so everything below resolves * whichever one is present in the document. * * Modals are built in JS rather than added to two templates, following * createImageModal() in message-utils.js. That also guarantees both chats get * an identical picker. */ (function () { 'use strict'; // Composer wiring, per page. The byte budgets mirror updateCharCounter() in // app.js (135) and dm.js (150) — the mesh packet limits, counted in UTF-8. const COMPOSERS = [ { input: 'messageInput', button: 'shareBtn', anchor: 'emojiBtn', maxBytes: 135 }, { input: 'dmMessageInput', button: 'dmShareBtn', anchor: 'dmEmojiBtn', maxBytes: 150 } ]; // api.py reports cached contacts with a label instead of the numeric type; // the token needs the number back. Label set from CONTACT_TYPE_NAMES. const TYPE_BY_LABEL = { COM: 1, REP: 2, ROOM: 3, SENS: 4 }; const TYPE_ICONS = { 1: 'bi-person-fill', 2: 'bi-broadcast', 3: 'bi-door-open-fill', 4: 'bi-thermometer-half' }; let _composer = null; // the active COMPOSERS entry let _pickerMap = null; // Leaflet instance for the location picker let _pickerMarker = null; let _pickedLatLng = null; // ---------------------------------------------------------------- helpers function activeComposer() { if (_composer) return _composer; _composer = COMPOSERS.find(c => document.getElementById(c.input)) || null; return _composer; } function inputEl() { const c = activeComposer(); return c ? document.getElementById(c.input) : null; } function maxBytes() { const c = activeComposer(); return c ? c.maxBytes : 135; } function typeLabelToNumber(label) { return TYPE_BY_LABEL[(label || '').toUpperCase()] || 1; } /** Refresh the page's own byte counter after we change the input value. */ function refreshCounter() { if (typeof updateCharCounter === 'function') updateCharCounter(); } // ------------------------------------------------------------- insertion /** * Insert a token at the caret, padding with spaces so it never fuses with * neighbouring words (which would stop it parsing). */ function insertShareToken(token) { const input = inputEl(); if (!input || !token) return; const bytes = MCShare.byteLength(token); if (bytes > maxBytes()) { // Nothing sensible to truncate: shortening a key or a secret // destroys it. Say so instead of sending something broken. showNotification(t('share.toast.too_long', { bytes: bytes, max: maxBytes() }), 'danger'); return; } const value = input.value; const start = input.selectionStart ?? value.length; const end = input.selectionEnd ?? value.length; let before = value.slice(0, start); let after = value.slice(end); if (before && !/\s$/.test(before)) before += ' '; if (after && !/^\s/.test(after)) after = ' ' + after; input.value = before + token + after; const caret = (before + token).length; input.setSelectionRange(caret, caret); input.focus(); refreshCounter(); updateSharePreview(); const total = MCShare.byteLength(input.value); if (total > maxBytes()) { showNotification(t('share.toast.over_budget', { bytes: total, max: maxBytes() }), 'warning'); } } // --------------------------------------------------------------- preview /** * Show the card the message will produce, above the composer, for as long as * the input holds a valid token. * * This is why the token is inserted as plain text rather than held in a * protected chip: the format has to stay byte-identical to what the official * app sends, and a preview gives the same reassurance without touching it. * Break the token by editing and the preview disappears — immediate feedback * that the message will not render as a card. */ function updateSharePreview() { const input = inputEl(); if (!input) return; let host = document.getElementById('sharePreview'); const tokens = MCShare.parse(input.value); if (!tokens.length) { if (host) host.remove(); return; } if (!host) { host = document.createElement('div'); host.id = 'sharePreview'; host.className = 'share-preview'; // Above the input group, inside the positioned composer container. const container = input.closest('.emoji-picker-container') || input.parentElement; container.parentElement.insertBefore(host, container); } // Render through the normal pipeline so the preview cannot drift from // what the recipient will actually see. host.innerHTML = `
${tHtml('share.preview.label')}
` + `
${processMessageContent(input.value, { isOwn: true })}
`; } // ----------------------------------------------------------- modal plumbing /** * Create (once) and return a Bootstrap modal shell. * z-index 1080 matches the other stacked modals in this app; the DM panel is * itself inside a modal, so a picker always opens on top of something. */ function ensureModal(id, titleKey, icon) { let modal = document.getElementById(id); if (modal) return modal; modal = document.createElement('div'); modal.id = id; modal.className = 'modal fade'; modal.tabIndex = -1; modal.style.zIndex = '1080'; modal.innerHTML = ``; document.body.appendChild(modal); return modal; } function showModal(modal) { bootstrap.Modal.getOrCreateInstance(modal).show(); } function hideModal(modal) { bootstrap.Modal.getOrCreateInstance(modal).hide(); } /** A single row in a picker list. */ function pickerRow({ icon, title, subtitle, badge, dataset }) { const attrs = Object.entries(dataset || {}) .map(([k, v]) => `data-${k}="${escapeHtmlAttribute(String(v))}"`).join(' '); return ``; } // ------------------------------------------------------- contact picker /** * Every contact we know, device memory and cache alike — the cache is where * adverts land, and those are exactly the contacts worth passing on. */ async function fetchAllContacts() { const [detailedResp, cachedResp] = await Promise.all([ fetch('/api/contacts/detailed'), fetch('/api/contacts/cached?format=full') ]); const detailed = await detailedResp.json(); const cached = await cachedResp.json(); const byPubkey = new Map(); if (detailed.success) { (detailed.contacts || []).forEach(c => { if (!c.public_key) return; byPubkey.set(c.public_key.toLowerCase(), { public_key: c.public_key.toLowerCase(), name: c.name || '', type: c.type || 1, onDevice: true }); }); } if (cached.success) { (cached.contacts || []).forEach(c => { const pk = (c.public_key || '').toLowerCase(); if (!pk || byPubkey.has(pk)) return; byPubkey.set(pk, { public_key: pk, name: c.name || '', // Only the label comes back on this endpoint. type: typeLabelToNumber(c.type_label), onDevice: false }); }); } return [...byPubkey.values()] .filter(c => c.name) .sort((a, b) => a.name.localeCompare(b.name)); } /** Our own identity, shared as a contact. */ async function fetchSelfContact() { const resp = await fetch('/api/device/info'); if (!resp.ok) return null; const data = await resp.json(); const info = (data.success && data.info) || null; if (!info || !info.public_key) return null; return { public_key: info.public_key.toLowerCase(), name: info.name || info.adv_name || '', // A companion app normally advertises type 1; trust the device but // fall back, matching loadDeviceShare() in app.js. type: info.adv_type || 1 }; } async function openContactPicker() { const modal = ensureModal('sharePickContactModal', 'share.pick.contact_title', 'bi-person-plus'); const body = modal.querySelector('.modal-body'); body.innerHTML = `
${tHtml('common.loading')}
`; showModal(modal); let self = null; let contacts = []; try { [self, contacts] = await Promise.all([fetchSelfContact(), fetchAllContacts()]); } catch (e) { console.error('Share: failed to load contacts', e); body.innerHTML = `
${tHtml('share.toast.action_failed')}
`; return; } body.innerHTML = `
`; const listEl = body.querySelector('#sharePickList'); const search = body.querySelector('#sharePickSearch'); /** * Render the matching rows, capped. * * The cache accumulates every advert ever seen — over 1500 contacts on a * busy mesh — and rendering them all costs thousands of DOM nodes for a * list nobody scrolls to the end of. The cap keeps the picker instant; * the search runs over the full set, so nothing is unreachable. */ const RENDER_CAP = 150; function renderRows(query) { const q = (query || '').trim().toLowerCase(); const matches = c => !q || c.name.toLowerCase().includes(q) || c.public_key.includes(q); // "Me" is pinned to the top rather than hidden behind its own menu // item: sharing yourself is the commonest case, and it is still just // a contact. const parts = []; if (self && matches(self)) { parts.push(pickerRow({ icon: TYPE_ICONS[MCShare.normalizeContactType(self.type)] || 'bi-person-fill', title: self.name, subtitle: MCShare.shortPubkey(self.public_key), badge: t('share.pick.me'), dataset: { pubkey: self.public_key, name: self.name, type: self.type } })); } const hits = contacts.filter(matches); hits.slice(0, RENDER_CAP).forEach(c => { parts.push(pickerRow({ icon: TYPE_ICONS[MCShare.normalizeContactType(c.type)] || 'bi-person-fill', title: c.name, subtitle: MCShare.shortPubkey(c.public_key), badge: c.onDevice ? '' : t('share.card.in_cache'), dataset: { pubkey: c.public_key, name: c.name, type: c.type } })); }); if (!parts.length) { parts.push(`
${tHtml('share.pick.empty')}
`); } else if (hits.length > RENDER_CAP) { parts.push(`
${ tHtml('share.pick.truncated', { shown: RENDER_CAP, total: hits.length })}
`); } listEl.innerHTML = parts.join(''); } renderRows(''); search.addEventListener('input', () => renderRows(search.value)); // Delegated, so it survives every re-render. listEl.addEventListener('click', (e) => { const row = e.target.closest('.share-pick-row'); if (!row) return; const token = MCShare.serializeContact({ public_key: row.dataset.pubkey, type: parseInt(row.dataset.type, 10) || 1, name: row.dataset.name }); hideModal(modal); insertShareToken(token); }); } // ------------------------------------------------------- channel picker async function openChannelPicker() { const modal = ensureModal('sharePickChannelModal', 'share.pick.channel_title', 'bi-hash'); const body = modal.querySelector('.modal-body'); body.innerHTML = `
${tHtml('common.loading')}
`; showModal(modal); let channels = []; let scopes = {}; try { const [chResp, scResp] = await Promise.all([ fetch('/api/channels'), fetch('/api/channels/scopes') ]); const chData = await chResp.json(); const scData = await scResp.json(); channels = (chData.success && chData.channels) || []; scopes = (scData.success && scData.scopes) || {}; } catch (e) { console.error('Share: failed to load channels', e); body.innerHTML = `
${tHtml('share.toast.action_failed')}
`; return; } body.innerHTML = `
${channels.length ? channels.map(ch => { const scope = scopes[String(ch.index)]; return pickerRow({ icon: 'bi-hash', title: ch.name, subtitle: MCShare.channelKindOf(ch.name) === 'hashtag' ? t('share.card.hashtag_channel') : t('share.card.private_channel'), badge: scope && scope.name ? t('share.card.region', { name: scope.name }) : '', dataset: { name: ch.name, secret: ch.key || '', scope: (scope && scope.name) || '' } }); }).join('') : `
${tHtml('share.pick.empty')}
`}
`; body.querySelectorAll('.share-pick-row').forEach(row => { row.addEventListener('click', () => { // The scope travels with the channel: without it the recipient // would transmit under the firmware default and not be heard. const token = MCShare.serializeChannel({ name: row.dataset.name, secret: row.dataset.secret, region_scope: row.dataset.scope || null }); if (!token) { showNotification(t('share.toast.channel_unshareable'), 'warning'); return; } hideModal(modal); insertShareToken(token); }); }); } // ------------------------------------------------------ location picker async function openLocationPicker() { const modal = ensureModal('sharePickLocationModal', 'share.pick.location_title', 'bi-geo-alt'); const body = modal.querySelector('.modal-body'); // Our own position and an arbitrary point live in one dialog, so both // cases stay one action rather than two menu entries. let self = null; try { const data = await fetchJson('/api/device/config'); const cfg = (data.success && data.config) || {}; if (MCShare.isValidLatLon(cfg.lat, cfg.lon)) self = { lat: cfg.lat, lon: cfg.lon }; } catch (e) { console.error('Share: failed to read device position', e); } _pickedLatLng = null; body.innerHTML = `${self ? `` : `
${tHtml('share.pick.no_position')}
`}
${tHtml('share.pick.map_hint')}
`; if (self) { body.querySelector('#shareUseOwnPos').addEventListener('click', () => { hideModal(modal); insertShareToken(MCShare.serializeLocation(self.lat, self.lon)); }); } const confirmBtn = body.querySelector('#sharePickConfirm'); confirmBtn.addEventListener('click', () => { if (!_pickedLatLng) return; hideModal(modal); insertShareToken(MCShare.serializeLocation(_pickedLatLng.lat, _pickedLatLng.lng)); }); // Leaflet cannot size itself in a hidden container, so build the map // only once the modal is actually on screen. modal.addEventListener('shown.bs.modal', function onShown() { modal.removeEventListener('shown.bs.modal', onShown); _pickerMap = L.map('sharePickMap'); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { referrerPolicy: 'strict-origin-when-cross-origin', attribution: '© OpenStreetMap contributors', maxZoom: 19 }).addTo(_pickerMap); _pickerMap.setView(self ? [self.lat, self.lon] : [52.0, 19.0], self ? 12 : 6); _pickerMap.on('click', (e) => { _pickedLatLng = e.latlng; if (_pickerMarker) _pickerMap.removeLayer(_pickerMarker); _pickerMarker = L.marker(e.latlng).addTo(_pickerMap); const hint = document.getElementById('sharePickMapHint'); if (hint) { hint.textContent = MCShare.serializeLocation(e.latlng.lat, e.latlng.lng); } confirmBtn.disabled = false; }); _pickerMap.invalidateSize(); }); // A fresh map each time the dialog opens; keeping a stale instance around // breaks after the container is replaced by the next innerHTML write. modal.addEventListener('hidden.bs.modal', function onHidden() { modal.removeEventListener('hidden.bs.modal', onHidden); if (_pickerMap) { _pickerMap.remove(); _pickerMap = null; } _pickerMarker = null; }); showModal(modal); } // ------------------------------------------------------------- share menu function buildShareMenu(container) { const popup = document.createElement('div'); popup.id = 'shareMenuPopup'; popup.className = 'share-menu-popup'; popup.style.display = 'none'; popup.innerHTML = ` `; container.appendChild(popup); popup.addEventListener('click', (e) => { const item = e.target.closest('[data-share-pick]'); if (!item) return; popup.style.display = 'none'; switch (item.dataset.sharePick) { case 'contact': openContactPicker(); break; case 'channel': openChannelPicker(); break; case 'location': openLocationPicker(); break; } }); return popup; } function initShareComposer() { const composer = activeComposer(); if (!composer) return; const input = document.getElementById(composer.input); const anchor = document.getElementById(composer.anchor); if (!input || !anchor) return; // Sit next to the emoji button, inside the same input group. const btn = document.createElement('button'); btn.type = 'button'; btn.id = composer.button; btn.className = 'btn btn-outline-secondary share-composer-btn'; btn.title = t('share.btn_title'); btn.innerHTML = ''; anchor.parentElement.insertBefore(btn, anchor); const container = input.closest('.emoji-picker-container') || btn.parentElement; const popup = buildShareMenu(container); // The DM composer starts disabled until a conversation is picked. Nothing // can be inserted into a disabled input, so the button follows its state. btn.disabled = input.disabled; new MutationObserver(() => { btn.disabled = input.disabled; }) .observe(input, { attributes: true, attributeFilter: ['disabled'] }); btn.addEventListener('click', (e) => { e.preventDefault(); if (input.disabled) return; popup.style.display = popup.style.display === 'none' ? 'flex' : 'none'; }); // Dismiss on an outside click, like the emoji and mentions popups. document.addEventListener('click', (e) => { if (popup.style.display === 'none') return; if (!popup.contains(e.target) && e.target !== btn && !btn.contains(e.target)) { popup.style.display = 'none'; } }); // Keep the preview in step with manual edits, so breaking a token by // hand visibly removes the card. input.addEventListener('input', updateSharePreview); // The composer is cleared on send; drop a stale preview with it. const form = input.closest('form'); if (form) form.addEventListener('submit', () => setTimeout(updateSharePreview, 0)); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initShareComposer); } else { initShareComposer(); } // Exposed for the send path and for tests. window.MCShareComposer = { insertShareToken: insertShareToken, updateSharePreview: updateSharePreview, openContactPicker: openContactPicker, openChannelPicker: openChannelPicker, openLocationPicker: openLocationPicker }; })();