feat(chat): add the share composer — "+" button, pickers and live preview

Completes the sharing feature: you can now send what the cards render.

A "+" button next to the emoji button opens a three-item menu — contact,
channel, location. Five actions in the official app collapse into three
because "me" and "my position" are not separate destinations, just the
first, pinned entry of the picker they belong to. Same number of taps,
one less menu level, and every entry behaves identically.

The payload is inserted into the input as plain text, byte-identical to
what the official app sends. A protected "chip" was considered and
dropped: it is unknown whether the official app renders a card when the
token is not the whole message body, and that risk is not worth the
cosmetics. The reassurance went into a live preview above the composer
instead, rendered through the very same processMessageContent() the
recipient will use — so it cannot drift. Edit the token by hand and the
preview vanishes, which is immediate feedback that the message will no
longer render as a card.

Details worth knowing:

- The contact picker lists device memory and cache together, since the
  cache is where adverts land and those are the contacts worth passing
  on. It renders at most 150 rows: this mesh has over 1500 cached
  contacts, and the full list costs thousands of DOM nodes for something
  nobody scrolls to the end of. Search runs over the whole set.
- The channel picker carries the region scope along, because a recipient
  transmitting under the firmware default would not match the sender.
- The location picker holds both cases in one dialog: our own position as
  a button, plus a Leaflet map for any other point.
- Byte budgets are enforced up front (135 channel / 150 DM). A token that
  cannot fit is refused rather than truncated — shortening a public key
  or a channel secret destroys it.
- The DM composer starts disabled until a conversation is chosen, so the
  button mirrors the input's disabled state via a MutationObserver.

Also fixes _selfInfo being populated only inside the map modal's shown
handler, which left it null on a fresh page — the location cards need our
own position at render time for distance and bearing.

Verified in a real browser against the live device: 32 assertions on the
channel composer, 13 on the DM panel (including its local map modal),
both suites green with no console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MarekWoandClaude Opus 5 committed 2026-08-17 11:12:58 +02:00
1 parent 2acf6f9f3a
commit e4a98a427e
7 files changed
+787 -3

No files matched your search

+129
View File
@@ -2484,3 +2484,132 @@ emoji-picker {
.share-card-state-warning {
color: var(--bs-warning-text-emphasis, #997404);
}
/* =============================================================================
Share Composer — the "+" button, its menu, the pickers and the live preview
============================================================================= */
.share-composer-btn {
display: flex;
align-items: center;
justify-content: center;
}
/* Anchored to .emoji-picker-container, the same positioned ancestor the emoji
and mentions popups use. */
.share-menu-popup {
position: absolute;
bottom: calc(100% + 0.5rem);
right: 0;
z-index: 1060;
display: flex;
flex-direction: column;
min-width: 13rem;
padding: 0.25rem;
background-color: var(--popup-bg);
border: 1px solid var(--border-color);
border-radius: 0.625rem;
box-shadow: var(--popup-shadow);
}
.share-menu-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
background: transparent;
border: 0;
border-radius: 0.5rem;
color: var(--text-primary);
text-align: left;
font-size: 0.9375rem;
}
.share-menu-item:hover {
background-color: var(--bg-hover);
}
.share-menu-item i {
color: var(--sender-color);
font-size: 1.05rem;
width: 1.25rem;
text-align: center;
}
/* --- pickers --- */
.share-pick-list {
max-height: 55vh;
overflow-y: auto;
}
.share-pick-row {
display: flex;
align-items: center;
gap: 0.625rem;
}
.share-pick-icon {
flex: 0 0 auto;
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
background-color: var(--sender-color);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
}
.share-pick-text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
}
.share-pick-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-pick-sub {
font-size: 0.75rem;
color: var(--text-muted);
font-family: var(--bs-font-monospace, monospace);
}
.share-pick-badge {
flex: 0 0 auto;
font-size: 0.7rem;
color: var(--text-muted);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
padding: 0.05rem 0.4rem;
}
.share-pick-coords {
font-family: var(--bs-font-monospace, monospace);
font-size: 0.8125rem;
opacity: 0.85;
margin-left: 0.375rem;
}
/* --- live preview above the composer ---
Present only while the input holds a valid token; it disappears the moment
the token is edited into something that will not render as a card. */
.share-preview {
padding: 0.375rem 0.75rem 0;
}
.share-preview-label {
font-size: 0.7rem;
color: var(--text-muted);
margin-bottom: 0.125rem;
}
.share-preview-body .share-card {
margin: 0;
}
+16 -3
View File
@@ -322,14 +322,27 @@ async function showAllContactsOnMap() {
*/
async function loadContactsGeoCache() {
try {
// Load detailed (device) and cached contacts in parallel
const [detailedResp, cachedResp] = await Promise.all([
// Load detailed (device) and cached contacts in parallel, plus our own
// device info. The latter used to be fetched only when the map modal
// opened, which left _selfInfo null on a fresh page — and the share
// cards need our position at render time to show distance and bearing.
const [detailedResp, cachedResp, deviceInfoResp] = await Promise.all([
fetch('/api/contacts/detailed'),
fetch('/api/contacts/cached?format=full')
fetch('/api/contacts/cached?format=full'),
fetch('/api/device/info')
]);
const detailedData = await detailedResp.json();
const cachedData = await cachedResp.json();
// /api/device/info answers 503 while the device is offline — that is not
// an error here, it just means we cannot offer distances yet.
if (deviceInfoResp.ok) {
const deviceInfoData = await deviceInfoResp.json();
if (deviceInfoData.success && deviceInfoData.info) {
_selfInfo = deviceInfoData.info;
}
}
contactsGeoCache = {};
contactsPubkeyMap = {};
contactsByPubkey = {};
+600
View File
@@ -0,0 +1,600 @@
/**
* 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 =
`<div class="share-preview-label">${tHtml('share.preview.label')}</div>` +
`<div class="share-preview-body">${processMessageContent(input.value, { isOwn: true })}</div>`;
}
// ----------------------------------------------------------- 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 =
`<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi ${icon}"></i> ${tHtml(titleKey)}</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"
aria-label="${tHtml('common.close')}"></button>
</div>
<div class="modal-body p-2"></div>
</div>
</div>`;
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 `<button type="button" class="list-group-item list-group-item-action share-pick-row" ${attrs}>
<span class="share-pick-icon"><i class="bi ${icon}"></i></span>
<span class="share-pick-text">
<span class="share-pick-title">${escapeHtml(title)}</span>
${subtitle ? `<span class="share-pick-sub">${escapeHtml(subtitle)}</span>` : ''}
</span>
${badge ? `<span class="share-pick-badge">${escapeHtml(badge)}</span>` : ''}
</button>`;
}
// ------------------------------------------------------- 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 = `<div class="text-center text-muted small py-3">${tHtml('common.loading')}</div>`;
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 = `<div class="text-center text-danger small py-3">${tHtml('share.toast.action_failed')}</div>`;
return;
}
body.innerHTML =
`<input type="search" class="form-control form-control-sm mb-2" id="sharePickSearch"
placeholder="${tHtml('share.pick.search_ph')}" autocomplete="off">
<div class="list-group share-pick-list" id="sharePickList"></div>`;
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(`<div class="text-center text-muted small py-3">${tHtml('share.pick.empty')}</div>`);
} else if (hits.length > RENDER_CAP) {
parts.push(`<div class="text-center text-muted small py-2">${
tHtml('share.pick.truncated', { shown: RENDER_CAP, total: hits.length })}</div>`);
}
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 = `<div class="text-center text-muted small py-3">${tHtml('common.loading')}</div>`;
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 = `<div class="text-center text-danger small py-3">${tHtml('share.toast.action_failed')}</div>`;
return;
}
body.innerHTML =
`<div class="list-group share-pick-list">
${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('')
: `<div class="text-center text-muted small py-3">${tHtml('share.pick.empty')}</div>`}
</div>`;
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 resp = await fetch('/api/device/config');
const data = await resp.json();
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
? `<button type="button" class="btn btn-sm btn-primary w-100 mb-2" id="shareUseOwnPos">
<i class="bi bi-crosshair"></i> ${tHtml('share.pick.my_position')}
<span class="share-pick-coords">${escapeHtml(MCShare.serializeLocation(self.lat, self.lon))}</span>
</button>`
: `<div class="alert alert-warning py-2 small mb-2">${tHtml('share.pick.no_position')}</div>`}
<div class="small text-muted mb-1" id="sharePickMapHint">${tHtml('share.pick.map_hint')}</div>
<div id="sharePickMap" style="height: 300px; width: 100%;"></div>
<div class="d-flex justify-content-end gap-2 mt-2">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">${tHtml('common.cancel')}</button>
<button type="button" class="btn btn-sm btn-primary" id="sharePickConfirm" disabled>${tHtml('coord.confirm')}</button>
</div>`;
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', {
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 =
`<button type="button" class="share-menu-item" data-share-pick="contact">
<i class="bi bi-person-plus"></i> ${tHtml('share.menu.contact')}
</button>
<button type="button" class="share-menu-item" data-share-pick="channel">
<i class="bi bi-hash"></i> ${tHtml('share.menu.channel')}
</button>
<button type="button" class="share-menu-item" data-share-pick="location">
<i class="bi bi-geo-alt"></i> ${tHtml('share.menu.location')}
</button>`;
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 = '<i class="bi bi-plus-circle"></i>';
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
};
})();
+3
View File
@@ -1540,6 +1540,9 @@
<!-- Message Content Processing Utilities (must load before app.js and dm.js) -->
<script src="{{ url_for('static', filename='js/message-utils.js') }}"></script>
<!-- Share composer: the "+" button and its pickers (needs message-utils.js) -->
<script src="{{ url_for('static', filename='js/share-composer.js') }}"></script>
<!-- Filter Utilities (must load before app.js) -->
<script src="{{ url_for('static', filename='js/filter-utils.js') }}"></script>
+3
View File
@@ -404,6 +404,9 @@
<!-- Message Content Processing Utilities (must load before dm.js) -->
<script src="{{ url_for('static', filename='js/message-utils.js') }}"></script>
<!-- Share composer: the "+" button and its pickers (needs message-utils.js) -->
<script src="{{ url_for('static', filename='js/share-composer.js') }}"></script>
<!-- Filter Utilities (must load before dm.js) -->
<script src="{{ url_for('static', filename='js/filter-utils.js') }}"></script>
+18
View File
@@ -1272,6 +1272,7 @@
"settings.toast.saved": "Settings saved",
"settings.toast.setting_network_error": "Network error saving setting",
"settings.toast.setting_save_failed": "Failed to save setting",
"share.btn_title": "Share a contact, channel or location",
"share.card.add_channel": "Add Channel",
"share.card.add_contact": "Add Contact",
"share.card.channel_conflict": "Name used by a different key",
@@ -1292,13 +1293,30 @@
"share.card.view_on_map": "View on map",
"share.card.you_shared_channel": "Shared channel",
"share.card.you_shared_contact": "Shared contact",
"share.menu.channel": "Share Channel",
"share.menu.contact": "Share Contact",
"share.menu.location": "Share Location",
"share.pick.channel_title": "Share a channel",
"share.pick.contact_title": "Share a contact",
"share.pick.empty": "Nothing to share here yet",
"share.pick.location_title": "Share a location",
"share.pick.map_hint": "Or tap the map to pick any point",
"share.pick.me": "Me",
"share.pick.my_position": "My device position",
"share.pick.no_position": "This device has no position set. Add one in Settings, or pick a point on the map below.",
"share.pick.search_ph": "Search contacts...",
"share.pick.truncated": "Showing {shown} of {total} — type to narrow it down",
"share.preview.label": "Will be sent as:",
"share.toast.action_failed": "Action failed",
"share.toast.channel_unshareable": "This channel has no key to share",
"share.toast.contact_added": "Contact {name} added to cache",
"share.toast.contact_pushed": "{name} saved on the device",
"share.toast.contact_updated": "Contact renamed to {name}",
"share.toast.map_unavailable": "The map is not available here",
"share.toast.over_budget": "The message is now {bytes} of {max} bytes and will not send",
"share.toast.scope_applied": "Region scope {name} applied to this channel",
"share.toast.scope_unknown": "Region \"{name}\" is not configured here — add it in Settings to match the sender's scope",
"share.toast.too_long": "This does not fit in one message ({bytes} of {max} bytes)",
"share.type.1": "Companion",
"share.type.2": "Repeater",
"share.type.3": "Room Server",
+18
View File
@@ -1346,6 +1346,7 @@
"settings.toast.saved": "Ustawienia zapisane",
"settings.toast.setting_network_error": "Błąd sieci przy zapisie ustawienia",
"settings.toast.setting_save_failed": "Nie udało się zapisać ustawienia",
"share.btn_title": "Udostępnij kontakt, kanał lub lokalizację",
"share.card.add_channel": "Dodaj kanał",
"share.card.add_contact": "Dodaj kontakt",
"share.card.channel_conflict": "Nazwa zajęta przez inny klucz",
@@ -1366,13 +1367,30 @@
"share.card.view_on_map": "Pokaż na mapie",
"share.card.you_shared_channel": "Udostępniony kanał",
"share.card.you_shared_contact": "Udostępniony kontakt",
"share.menu.channel": "Udostępnij kanał",
"share.menu.contact": "Udostępnij kontakt",
"share.menu.location": "Udostępnij lokalizację",
"share.pick.channel_title": "Udostępnij kanał",
"share.pick.contact_title": "Udostępnij kontakt",
"share.pick.empty": "Nie ma tu jeszcze czego udostępnić",
"share.pick.location_title": "Udostępnij lokalizację",
"share.pick.map_hint": "Albo dotknij mapy, aby wybrać dowolny punkt",
"share.pick.me": "Ja",
"share.pick.my_position": "Pozycja mojego urządzenia",
"share.pick.no_position": "To urządzenie nie ma ustawionej pozycji. Dodaj ją w Ustawieniach albo wybierz punkt na mapie poniżej.",
"share.pick.search_ph": "Szukaj kontaktów...",
"share.pick.truncated": "Pokazano {shown} z {total} — wpisz, aby zawęzić",
"share.preview.label": "Zostanie wysłane jako:",
"share.toast.action_failed": "Nie udało się wykonać akcji",
"share.toast.channel_unshareable": "Ten kanał nie ma klucza do udostępnienia",
"share.toast.contact_added": "Kontakt {name} dodany do pamięci podręcznej",
"share.toast.contact_pushed": "{name} zapisany w urządzeniu",
"share.toast.contact_updated": "Nazwa kontaktu zmieniona na {name}",
"share.toast.map_unavailable": "Mapa jest tu niedostępna",
"share.toast.over_budget": "Wiadomość ma teraz {bytes} z {max} bajtów i nie zostanie wysłana",
"share.toast.scope_applied": "Region {name} przypisany do tego kanału",
"share.toast.scope_unknown": "Region \"{name}\" nie jest tu skonfigurowany — dodaj go w Ustawieniach, aby zgadzać się z nadawcą",
"share.toast.too_long": "To nie zmieści się w jednej wiadomości ({bytes} z {max} bajtów)",
"share.type.1": "Companion",
"share.type.2": "Repeater",
"share.type.3": "Room Server",