diff options
| author | Adam Soltys <[email protected]> | 2026-09-29 11:18:21 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-29 11:18:21 -0700 |
| commit | 689e21d5e77765661c44d60c10b18134b995162a (patch) | |
| tree | 2a6e878c9b8e400d3fcd60022d72f7d4bf3030ca /src/features | |
| parent | c7186db46f6e45969f01709bc4a0a9472a40dff1 (diff) | |
| download | coinosv3-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/features')
| -rw-r--r-- | src/features/messages.js | 44 |
1 files changed, 33 insertions, 11 deletions
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(); } }, |
