diff options
| author | Adam Soltys <[email protected]> | 2026-09-12 15:37:48 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-12 15:37:48 -0700 |
| commit | 9356684a78cddd106d82296a50a637406908cffd (patch) | |
| tree | 489bb638daee020849201729f6cb4d13fe08340c | |
| parent | 60e755286302647d93bc82070c61fcf93ddbc3b9 (diff) | |
| download | coinosv3-9356684a78cddd106d82296a50a637406908cffd.tar.gz coinosv3-9356684a78cddd106d82296a50a637406908cffd.zip | |
A reply can carry a picture, and the WebView build has a name you can say.
The reply box gets the same paperclip the post composer has: one uploader
now, handing the URL to whichever draft asked for it, and the reply's own
imeta tag describing what it carries.
And the UnifiedPush build is graphene.apk — what people will actually call
it, and short enough to read down a phone line.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
| -rw-r--r-- | build.js | 6 | ||||
| -rw-r--r-- | src/features/messages.js | 39 | ||||
| -rw-r--r-- | static/graphene.apk (renamed from static/coinos-unifiedpush.apk) | bin | 38692 -> 38692 bytes | |||
| -rw-r--r-- | tools/post-actions-test.js | 20 |
4 files changed, 56 insertions, 9 deletions
@@ -470,9 +470,9 @@ if (import.meta.main) { await Bun.write('dist/.well-known/assetlinks.json', Bun.file('static/.well-known/assetlinks.json')); } // The Android builds. coinos.apk is the TWA (Chrome renders the site); - // coinos-unifiedpush.apk runs the wallet in its own WebView so a phone - // without Google Play Services can be woken — see android/. - for (const apk of ['coinos.apk', 'coinos-unifiedpush.apk']) { + // graphene.apk runs the wallet in its own WebView so a phone without + // Google Play Services can be woken — see android/. + for (const apk of ['coinos.apk', 'graphene.apk']) { if (await Bun.file('static/' + apk).exists()) { await Bun.write('dist/' + apk, Bun.file('static/' + apk)); } diff --git a/src/features/messages.js b/src/features/messages.js index 5ff1492..9fbd19b 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -3143,6 +3143,8 @@ export function messagesFeature(ctx) { ...(c.root ? [c.root.pubkey] : []), ...target.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]), ])].filter((pk) => pk !== id.pubkey).slice(0, 8); + const imeta = (ui.postMedia || []).filter((m) => m && m.url && text.includes(m.url)) + .map((m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : [])]); const partial = { kind: 1, content: text, @@ -3151,6 +3153,7 @@ export function messagesFeature(ctx) { ['e', rootId, '', 'root'], ...(target.id !== rootId ? [['e', target.id, '', 'reply']] : []), ...pTags.map((pk) => ['p', pk]), + ...imeta, CLIENT_TAG, ], }; @@ -3175,8 +3178,27 @@ export function messagesFeature(ctx) { onInput: (e) => { s.draft = e.target.value; }, onKeydown: (e) => { if (e.key === 'Enter') e.target.parentElement.querySelector('.thread-reply-send')?.click(); }, }), + // A reply can carry a picture too — same upload, same imeta tag, same + // paperclip. The URL lands in the draft, which is what every client + // reads as the media. + ctx.uploadImage ? h('button', { + class: 'attach-btn', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading, + onClick: () => document.getElementById('reply-file')?.click(), + }, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null, + h('input', { + type: 'file', id: 'reply-file', accept: 'image/*,video/*', style: 'display:none', + onChange: async (e) => { + const f = e.target.files && e.target.files[0]; + e.target.value = ''; + await attachTo(f, (url) => { + s.draft = ((s.draft || '').replace(/\s+$/, '') + ' ' + url).trim(); + const inp = document.querySelector('.thread-reply-input'); + if (inp) inp.value = s.draft; + }); + }, + }), h('button', { - class: 'btn-primary thread-reply-send', disabled: !!s.sending, + class: 'btn-primary thread-reply-send', disabled: !!s.sending || !!ui.postUploading, onClick: async () => { const text = (s.draft || '').trim(); if (!text) return; @@ -3184,6 +3206,7 @@ export function messagesFeature(ctx) { try { await publishReply(c, s, text); s.draft = ''; + ui.postMedia = (ui.postMedia || []).filter((m) => !text.includes(m.url)); // the input may still be focused, and the morph won't touch a // focused field's value — clear it by hand const inp = document.querySelector('.thread-reply-input'); @@ -3646,19 +3669,23 @@ export function messagesFeature(ctx) { // client reads media — with a NIP-92 imeta tag published beside it for the // ones that would rather have the metadata than sniff the URL. const composeText = () => (ui.profCompose == null ? draftFor(POST_DRAFT) : ui.profCompose || ''); - async function attachMedia(file) { + // Upload, then hand the URL to whichever draft asked for it. + async function attachTo(file, place) { if (!file || !ctx.uploadImage) return; ui.postUploading = true; render(); try { const url = await ctx.uploadImage(file); (ui.postMedia ||= []).push({ url, m: file.type || '' }); - const cur = composeText().replace(/\s+$/, ''); - const next = (cur ? cur + '\n' : '') + url; - ui.profCompose = next; - setDraft(POST_DRAFT, next); + place(url); } catch (e) { toast(e.message || String(e)); } ui.postUploading = false; render(); } + const attachMedia = (file) => attachTo(file, (url) => { + const cur = composeText().replace(/\s+$/, ''); + const next = (cur ? cur + '\n' : '') + url; + ui.profCompose = next; + setDraft(POST_DRAFT, next); + }); const CLIP = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>'; function postComposer() { if (ui.profCompose == null && !draftFor(POST_DRAFT)) return null; diff --git a/static/coinos-unifiedpush.apk b/static/graphene.apk Binary files differindex 8e6de0f..8e6de0f 100644 --- a/static/coinos-unifiedpush.apk +++ b/static/graphene.apk diff --git a/tools/post-actions-test.js b/tools/post-actions-test.js index e6d6c9f..8cc525c 100644 --- a/tools/post-actions-test.js +++ b/tools/post-actions-test.js @@ -81,6 +81,26 @@ try { const draft = await page.evaluate(() => (document.querySelector('.chat-page textarea') || {}).value || ''); check('quoting opens the composer with the reference in it', /nostr:nevent1/.test(draft), draft.slice(0, 40) || 'empty'); + // A reply can carry a picture, the same as a post can. + await page.evaluate(() => { const b = [...document.querySelectorAll('.note-act')].find((x) => x.getAttribute('aria-label') === 'Reply'); if (b) b.click(); }); + await sleep(2500); + const reply = await page.evaluate(() => { + const box = document.querySelector('.thread-reply-input'); + if (!box) return { box: false }; + const row = box.parentElement; + return { + box: true, + attach: !!row.querySelector('.attach-btn'), + file: !!row.querySelector('#reply-file'), + accepts: (row.querySelector('#reply-file') || {}).accept, + }; + }); + check('the reply box has the same paperclip', reply.box && reply.attach && reply.file, JSON.stringify(reply)); + check('...taking pictures and video', /image/.test(reply.accepts || '') && /video/.test(reply.accepts || ''), reply.accepts || 'none'); + // back out of the thread the reply check opened + await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((x) => x.textContent.trim() === 'Back' || x.textContent.trim() === '← Back'); if (b) b.click(); }); + await sleep(1500); + // the ellipsis sheet, and muting from it await page.evaluate(() => { const b = [...document.querySelectorAll('.btn-sm')].find((x) => x.textContent.trim() === '⋯'); if (b) b.click(); }); await sleep(600); |
