aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-12 15:37:48 -0700
committerAdam Soltys <[email protected]>2026-09-12 15:37:48 -0700
commit9356684a78cddd106d82296a50a637406908cffd (patch)
tree489bb638daee020849201729f6cb4d13fe08340c
parent60e755286302647d93bc82070c61fcf93ddbc3b9 (diff)
downloadcoinosv3-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.js6
-rw-r--r--src/features/messages.js39
-rw-r--r--static/graphene.apk (renamed from static/coinos-unifiedpush.apk)bin38692 -> 38692 bytes
-rw-r--r--tools/post-actions-test.js20
4 files changed, 56 insertions, 9 deletions
diff --git a/build.js b/build.js
index ca0f651..ae9aee9 100644
--- a/build.js
+++ b/build.js
@@ -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
index 8e6de0f..8e6de0f 100644
--- a/static/coinos-unifiedpush.apk
+++ b/static/graphene.apk
Binary files differ
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);