aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-29 11:18:21 -0700
committerAdam Soltys <[email protected]>2026-09-29 11:18:21 -0700
commit689e21d5e77765661c44d60c10b18134b995162a (patch)
tree2a6e878c9b8e400d3fcd60022d72f7d4bf3030ca /src
parentc7186db46f6e45969f01709bc4a0a9472a40dff1 (diff)
downloadcoinosv3-689e21d5e77765661c44d60c10b18134b995162a.tar.gz
coinosv3-689e21d5e77765661c44d60c10b18134b995162a.zip
Composers take pictures from the keyboard: the reply, post and chat fields are contenteditable.
Android keyboards (Gboard GIFs, stickers) only offer images to fields that accept them, and Chrome says so for contenteditable, never <textarea> — hence the greyed-out GIF key and "This app does not support images here". <coinos-text> (src/rich-text.js) is a contenteditable that acts like a textarea to the code around it (value, selectionStart/End, setSelection- Range, placeholder), keeps text plain, and hands any pasted/dropped/ keyboard picture to onMedia — the paperclip's upload. The morph syncs its value like a field's and leaves its own editing attributes alone. composer-media-test: types like a textarea, strips pasted formatting, a pasted GIF uploads and lands in the reply, still editable after repaint. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp
Diffstat (limited to 'src')
-rw-r--r--src/app.js16
-rw-r--r--src/features/messages.js44
-rw-r--r--src/rich-text.js181
-rw-r--r--src/style.css10
4 files changed, 237 insertions, 14 deletions
diff --git a/src/app.js b/src/app.js
index e9412ec..0bf3086 100644
--- a/src/app.js
+++ b/src/app.js
@@ -6,6 +6,7 @@
import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js';
import { qrSvg } from './qr.js';
+import './rich-text.js'; // <coinos-text>, the composers' field
import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './recipient-search.js';
import { npubOf, seedPubkey, neventOf } from './nostr.js';
import { nip98Header } from './nip98.js';
@@ -233,7 +234,10 @@ function morph(a, b) {
// <details open> is the user's doing (the browser toggles the attribute on
// click), like a field's value: a render that doesn't set `open` leaves it
// alone instead of collapsing the section on every background repaint.
- const userOwned = (at) => a.nodeName === 'DETAILS' && at.name === 'open';
+ // <coinos-text> sets its own editing attributes when it joins the page;
+ // the freshly built copy doesn't have them yet, and must not strip them
+ const userOwned = (at) => (a.nodeName === 'DETAILS' && at.name === 'open')
+ || (a.nodeName === 'COINOS-TEXT' && /^(contenteditable|role|aria-multiline|aria-placeholder|data-empty)$/.test(at.name));
for (const at of [...a.attributes]) {
if (b.hasAttribute(at.name) || userOwned(at)) continue;
if (at.name === 'src' && b.hasAttribute('data-lazy-src')) continue; // the lazy source stands for it
@@ -261,6 +265,12 @@ function morph(a, b) {
}
if (a.disabled !== b.disabled) a.disabled = b.disabled;
}
+ // the contenteditable field: its text is its value, owned like a
+ // textarea's — synced when not being edited, children never diffed
+ if (tag === 'COINOS-TEXT') {
+ if (document.activeElement !== a && a.value !== b.value) a.value = b.value;
+ return;
+ }
// innerHTML-authored subtrees (svg icons, QRs): compare source, not nodes
if (b._html != null) {
if (a._html !== b._html) a.innerHTML = b._html;
@@ -724,7 +734,7 @@ function renderInner() {
// payment push, an SP scan) can't kick the user out of a field they're editing.
const a = document.activeElement;
let fpath = null, selStart = null, selEnd = null;
- if (a && root.contains(a) && /^(INPUT|SELECT|TEXTAREA)$/.test(a.tagName)) {
+ if (a && root.contains(a) && /^(INPUT|SELECT|TEXTAREA|COINOS-TEXT)$/.test(a.tagName)) {
fpath = focusPath(a);
try { selStart = a.selectionStart; selEnd = a.selectionEnd; } catch {}
}
@@ -789,7 +799,7 @@ function renderInner() {
try { document.documentElement.classList.toggle('no-scroll', !!ui.lightbox); } catch {}
if (fpath) {
const el = nodeAtPath(fpath);
- if (el && el !== a && /^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName)) {
+ if (el && el !== a && /^(INPUT|SELECT|TEXTAREA|COINOS-TEXT)$/.test(el.tagName)) {
try { el.focus({ preventScroll: true }); if (selStart != null && el.setSelectionRange) el.setSelectionRange(selStart, selEnd); } catch {}
}
}
diff --git a/src/features/messages.js b/src/features/messages.js
index b5b4c01..11204f5 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -5495,6 +5495,19 @@ export function messagesFeature(ctx) {
// so outright — plenty of perfectly good picture URLs carry no extension
// (a CDN path, a /media/ route), and ![…] is the author telling us what it
// is, which beats guessing from the filename.
+ // A picture is fitted inside its box (object-fit: contain), so a tall one
+ // leaves blank bands either side. A tap there is a tap on the post — it
+ // opens the thread — not on the picture.
+ function onPicture(e) {
+ const el = e.currentTarget;
+ const r = el.getBoundingClientRect();
+ const nw = el.naturalWidth, nh = el.naturalHeight;
+ if (!nw || !nh || !r.width || !r.height) return true;
+ const k = Math.min(r.width / nw, r.height / nh);
+ const w = nw * k, hh = nh * k;
+ const x = e.clientX - (r.left + (r.width - w) / 2), y = e.clientY - (r.top + (r.height - hh) / 2);
+ return x >= 0 && x <= w && y >= 0 && y <= hh;
+ }
function urlNode(url, { label = null, isImage = false } = {}) {
if (/\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i.test(url)) {
return videoNode(url, { stable: !!feedPaint });
@@ -5508,7 +5521,7 @@ export function messagesFeature(ctx) {
src: url, class: 'note-img clickable', loading: feedPaint ? 'eager' : 'lazy', alt: label || '',
width: size?.width, height: size?.height,
style: size ? 'height:auto;aspect-ratio:' + size.width + '/' + size.height : undefined,
- onClick: (e) => { e.stopPropagation(); ctx.openImage && ctx.openImage(url); },
+ onClick: (e) => { if (!onPicture(e)) return; e.stopPropagation(); ctx.openImage && ctx.openImage(url); },
// a picture we were TOLD was a picture and which won't load leaves
// nothing behind — the alt text is already in the sentence above it
onError: (e) => { if (!size) e.target.style.display = 'none'; },
@@ -6110,7 +6123,7 @@ export function messagesFeature(ctx) {
ui.msgView = 'feed';
feedNow();
render();
- setTimeout(() => { const el = document.querySelector('.chat-page textarea'); if (el) { el.focus(); el.setSelectionRange(0, 0); } }, 80);
+ setTimeout(() => { const el = document.querySelector('.chat-page coinos-text'); if (el) { el.focus(); el.setSelectionRange(0, 0); } }, 80);
}
// ---- mute list (NIP-51 kind 10000) ---------------------------------------
@@ -7054,11 +7067,17 @@ export function messagesFeature(ctx) {
// A textarea that grows with the reply, like the chat composer: Enter
// sends, Shift+Enter (or Ctrl+J) breaks the line — a reply used to be
// a one-line field with no way to write a paragraph.
- h('textarea', {
+ h('coinos-text', {
class: 'thread-reply-input', placeholder: t('threadReplyHint'),
- rows: String(Math.min(6, Math.max(2, (s.draft || '').split('\n').length))),
- style: 'font-family:var(--sans);resize:none;max-height:160px;overflow-y:auto;line-height:1.4;width:100%;box-sizing:border-box',
+ style: 'font-family:var(--sans);min-height:64px;max-height:160px',
value: s.draft || '',
+ // a keyboard GIF or a pasted picture: attached like the paperclip's
+ onMedia: (f) => attachTo(f, (url) => {
+ s.draft = ((s.draft || '').replace(/\s+$/, '') + ' ' + url).trim();
+ const inp = document.querySelector('.thread-reply-input');
+ if (inp) inp.value = s.draft;
+ s.preview = true;
+ }),
// a render per keystroke only while the preview is open; the morph
// leaves a focused field alone, so this can't fight the typing
onInput: (e) => { s.draft = e.target.value; growComposer(e.target); if (s.preview) render(); },
@@ -7758,10 +7777,12 @@ export function messagesFeature(ctx) {
if (ui.profCompose == null && !draftFor(POST_DRAFT)) return null;
const text = composeText();
return h('div', { class: 'col', style: 'gap:8px' },
- h('textarea', {
- rows: '3', placeholder: t('profComposePh'),
- style: 'font-family:var(--sans);min-height:64px',
+ h('coinos-text', {
+ class: 'post-input', placeholder: t('profComposePh'),
+ style: 'font-family:var(--sans);min-height:84px;max-height:320px',
value: text,
+ // the field is focused, so the morph leaves it: put the URL in by hand
+ onMedia: (f) => attachMedia(f).then(() => { const el = document.querySelector('.post-input'); if (el) el.value = composeText(); }),
onInput: (ev) => {
ui.profCompose = ev.target.value;
setDraft(POST_DRAFT, ev.target.value);
@@ -8498,9 +8519,10 @@ export function messagesFeature(ctx) {
type: 'file', id: 'msg-file', accept: 'image/*,video/*', style: 'display:none',
onChange: (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (f) onAttach(f); },
}) : null,
- h('textarea', {
- class: 'grow', id: 'msg-draft', placeholder, rows: String(Math.min(5, draftFor(draftKey).split('\n').length)),
- value: draftFor(draftKey), maxlength: '2000',
+ h('coinos-text', {
+ class: 'grow', id: 'msg-draft', placeholder,
+ value: draftFor(draftKey),
+ onMedia: onAttach ? (f) => onAttach(f) : null,
onInput: (e) => { setDraft(draftKey, e.target.value); growComposer(e.target); updateEmojiAc(e.target, draftKey); if (onType && e.target.value) onType(); },
onClick: (e) => updateEmojiAc(e.target, draftKey), // the caret moved
onBlur: () => { if (ui.emojiAc) { ui.emojiAc = null; render(); } },
diff --git a/src/rich-text.js b/src/rich-text.js
new file mode 100644
index 0000000..7003f5c
--- /dev/null
+++ b/src/rich-text.js
@@ -0,0 +1,181 @@
+// <coinos-text>: a plain-text field built on contenteditable, standing in
+// for <textarea> in the composers.
+//
+// Why: Android keyboards (Gboard's GIFs, stickers, image suggestions) only
+// offer pictures to a field that says it accepts them, and Chrome says so
+// for contenteditable — never for <textarea>, where the GIF key sits greyed
+// out with "This app does not support images here". So the field is
+// editable HTML that behaves, to the code around it, like a textarea:
+// `value`, `selectionStart`/`selectionEnd` (read and write),
+// `setSelectionRange`, a `placeholder`, Enter handled by the page. Text
+// stays plain — pasted formatting is dropped — and any picture that arrives
+// (a keyboard GIF, a pasted or dropped image) is handed to `onmedia(file)`
+// as a File, the same path as the paperclip, instead of landing in the text.
+
+const BLOCK = new Set(['DIV', 'P', 'LI']);
+
+// The field's text, newlines where the editor put <br> or a new block.
+function textOf(root) {
+ let out = '';
+ const walk = (n) => {
+ for (const c of n.childNodes) {
+ if (c.nodeType === 3) out += c.data;
+ else if (c.nodeName === 'BR') out += '\n';
+ else if (c.nodeType === 1) {
+ if (BLOCK.has(c.nodeName) && out && !out.endsWith('\n')) out += '\n';
+ walk(c);
+ }
+ }
+ };
+ walk(root);
+ // an editor leaves a lone trailing <br> to hold an empty last line open
+ return out.endsWith('\n') && root.lastChild && root.lastChild.nodeName === 'BR' ? out.slice(0, -1) : out;
+}
+
+// Text length from the start of `root` up to (node, offset).
+function offsetOf(root, node, offset) {
+ const r = document.createRange();
+ r.selectNodeContents(root);
+ try { r.setEnd(node, offset); } catch { return textOf(root).length; }
+ const frag = r.cloneContents();
+ const div = document.createElement('div');
+ div.append(frag);
+ let t = textOf(div);
+ // a <br> right at the caret counts as the newline it is
+ if (div.lastChild && div.lastChild.nodeName === 'BR') t += '\n';
+ return t.length;
+}
+
+// (node, offset) for a text position; the field is normalised to text nodes
+// and <br>s, so that is all this walks.
+function pointAt(root, pos) {
+ let left = pos;
+ for (const c of root.childNodes) {
+ if (c.nodeType === 3) {
+ if (left <= c.data.length) return [c, left];
+ left -= c.data.length;
+ } else if (c.nodeName === 'BR') {
+ if (left === 0) return [root, [...root.childNodes].indexOf(c)];
+ left -= 1;
+ }
+ }
+ return [root, root.childNodes.length];
+}
+
+const mediaOf = (dt) => [...((dt && dt.files) || [])].filter((f) => /^(image|video)\//.test(f.type));
+
+class CoinosText extends HTMLElement {
+ constructor() {
+ super();
+ this._pendingValue = null;
+ this.addEventListener('paste', (e) => this._onPaste(e));
+ this.addEventListener('drop', (e) => {
+ const files = mediaOf(e.dataTransfer);
+ if (files.length) { e.preventDefault(); this._media(files); }
+ });
+ this.addEventListener('beforeinput', (e) => this._onBeforeInput(e));
+ this.addEventListener('input', () => this._tidy());
+ }
+ connectedCallback() {
+ if (!this.hasAttribute('contenteditable')) this.setAttribute('contenteditable', 'true');
+ this.setAttribute('role', 'textbox');
+ this.setAttribute('aria-multiline', 'true');
+ if (this.hasAttribute('placeholder')) this.setAttribute('aria-placeholder', this.getAttribute('placeholder'));
+ this._empty();
+ }
+
+ get value() { return textOf(this); }
+ set value(v) {
+ v = v == null ? '' : String(v);
+ if (v === this.value) return;
+ // text nodes and <br>s only: what pointAt walks
+ this.replaceChildren(...v.split('\n').flatMap((line, i) => [...(i ? [document.createElement('br')] : []), ...(line ? [document.createTextNode(line)] : [])]));
+ if (v.endsWith('\n')) this.append(document.createElement('br'));
+ this._empty();
+ }
+ get placeholder() { return this.getAttribute('placeholder') || ''; }
+ set placeholder(v) { this.setAttribute('placeholder', v); }
+
+ get selectionStart() { return this._sel()[0]; }
+ get selectionEnd() { return this._sel()[1]; }
+ set selectionStart(v) { const [, e] = this._sel(); this.setSelectionRange(v, Math.max(v, e)); }
+ set selectionEnd(v) { const [s] = this._sel(); this.setSelectionRange(Math.min(s, v), v); }
+ _sel() {
+ const sel = document.getSelection();
+ if (!sel || !sel.rangeCount || !this.contains(sel.anchorNode)) { const n = this.value.length; return [n, n]; }
+ const r = sel.getRangeAt(0);
+ return [offsetOf(this, r.startContainer, r.startOffset), offsetOf(this, r.endContainer, r.endOffset)];
+ }
+ setSelectionRange(start, end = start) {
+ this.normalize();
+ const sel = document.getSelection();
+ if (!sel) return;
+ const r = document.createRange();
+ const [sn, so] = pointAt(this, start);
+ const [en, eo] = pointAt(this, end);
+ try { r.setStart(sn, so); r.setEnd(en, eo); sel.removeAllRanges(); sel.addRange(r); } catch {}
+ }
+
+ // Insert text at the caret as the editor would (undo history intact).
+ _insert(text) {
+ if (!document.execCommand('insertText', false, text)) {
+ const s = this.selectionStart, e = this.selectionEnd, v = this.value;
+ this.value = v.slice(0, s) + text + v.slice(e);
+ this.setSelectionRange(s + text.length);
+ this.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+ }
+ _media(files) {
+ for (const f of files) {
+ if (typeof this.onmedia === 'function') this.onmedia(f);
+ }
+ }
+ _onPaste(e) {
+ const dt = e.clipboardData;
+ const files = mediaOf(dt);
+ if (files.length) { e.preventDefault(); this._media(files); return; }
+ // plain text only: no fonts, links or colours from wherever it was copied
+ e.preventDefault();
+ const text = dt ? dt.getData('text/plain') : '';
+ if (text) this._insert(text.replace(/\r\n?/g, '\n'));
+ }
+ _onBeforeInput(e) {
+ // keyboard-inserted pictures (Android's commitContent) and drops arrive
+ // as insertions carrying a DataTransfer
+ if (e.dataTransfer) {
+ const files = mediaOf(e.dataTransfer);
+ if (files.length) { e.preventDefault(); this._media(files); return; }
+ if (/^insertFrom/.test(e.inputType)) {
+ e.preventDefault();
+ const text = e.dataTransfer.getData('text/plain');
+ if (text) this._insert(text.replace(/\r\n?/g, '\n'));
+ return;
+ }
+ }
+ // a new line is a newline, not a new <div>
+ if (e.inputType === 'insertParagraph') { e.preventDefault(); this._insert('\n'); return; }
+ if (/^format/.test(e.inputType)) e.preventDefault(); // no bold/italic shortcuts
+ }
+ // Anything that isn't text or <br> (an <img> a keyboard managed to put
+ // in, a styled span) is taken out; a picture goes to onmedia.
+ _tidy() {
+ for (const img of this.querySelectorAll('img')) {
+ const src = img.getAttribute('src');
+ img.remove();
+ if (src) fetch(src).then((r) => r.blob()).then((b) => {
+ if (/^(image|video)\//.test(b.type)) this._media([new File([b], 'pasted.' + (b.type.split('/')[1] || 'png'), { type: b.type })]);
+ }).catch(() => {});
+ }
+ if ([...this.children].some((c) => c.nodeName !== 'BR')) {
+ const s = this.selectionStart, v = this.value;
+ this.value = v;
+ if (document.activeElement === this) this.setSelectionRange(Math.min(s, v.length));
+ }
+ this._empty();
+ }
+ _empty() { this.toggleAttribute('data-empty', !this.value); }
+}
+
+if (typeof customElements !== 'undefined' && !customElements.get('coinos-text')) {
+ customElements.define('coinos-text', CoinosText);
+}
diff --git a/src/style.css b/src/style.css
index 9b0bf45..9bdaf3c 100644
--- a/src/style.css
+++ b/src/style.css
@@ -248,6 +248,16 @@ select {
background-repeat: no-repeat; background-position: right 14px center;
}
textarea { resize: vertical; min-height: 84px; font-family: var(--mono); font-size: 15px; }
+/* The composers' text field (src/rich-text.js): a textarea to look at and
+ type in, contenteditable underneath so keyboards offer GIFs and images. */
+coinos-text {
+ display: block; width: 100%; box-sizing: border-box; font: inherit; color: var(--ink); background: var(--paper);
+ border: 1px solid var(--line-strong); border-radius: 11px; padding: 11px 13px;
+ white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; line-height: 1.4; cursor: text;
+}
+coinos-text:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
+coinos-text[data-empty]::before { content: attr(placeholder); color: var(--muted); pointer-events: none; }
+.chat-compose coinos-text { flex: 1; min-width: 0; padding: 10px 13px; max-height: 120px; }
.mono-input { font-family: var(--mono); }
.input-group { display: flex; gap: 8px; }
.input-group input { flex: 1; }