diff --git a/app/static/css/style.css b/app/static/css/style.css
index d945859..94e7e71 100644
--- a/app/static/css/style.css
+++ b/app/static/css/style.css
@@ -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;
+}
diff --git a/app/static/js/app.js b/app/static/js/app.js
index b08ef4e..c67dac3 100644
--- a/app/static/js/app.js
+++ b/app/static/js/app.js
@@ -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 = {};
diff --git a/app/static/js/share-composer.js b/app/static/js/share-composer.js
new file mode 100644
index 0000000..0780a19
--- /dev/null
+++ b/app/static/js/share-composer.js
@@ -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 =
+ `
${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 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
+ ? ``
+ : `${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', {
+ 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
+ };
+})();
diff --git a/app/templates/base.html b/app/templates/base.html
index cfea4e2..4f3769a 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -1540,6 +1540,9 @@
+
+
+
diff --git a/app/templates/dm.html b/app/templates/dm.html
index 6b88463..5380a6e 100644
--- a/app/templates/dm.html
+++ b/app/templates/dm.html
@@ -404,6 +404,9 @@
+
+
+
diff --git a/app/translations/en.json b/app/translations/en.json
index 44fddcb..c92ea20 100644
--- a/app/translations/en.json
+++ b/app/translations/en.json
@@ -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",
diff --git a/app/translations/pl.json b/app/translations/pl.json
index 6a88027..8eab242 100644
--- a/app/translations/pl.json
+++ b/app/translations/pl.json
@@ -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",