aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-29 09:01:29 -0700
committerAdam Soltys <[email protected]>2026-10-29 09:01:29 -0700
commit01b6c11555bb9a131dbcabe897327a9f928ccf1b (patch)
treedb5e7f3d97f6d1cf0430b3335f435cb1f05ab89b
parent5e3e94648885512572a71e44e80f7c8f95bdd756 (diff)
downloadcoinosv3-01b6c11555bb9a131dbcabe897327a9f928ccf1b.tar.gz
coinosv3-01b6c11555bb9a131dbcabe897327a9f928ccf1b.zip
Scheduled posts: the composer's clock button opens a "Post at" time (an hour ahead, on the quarter hour) and turns Post into Schedule. The note is signed now with that created_at, so a remote signer asks while you're there, and handed to the notifier, which publishes it at that time; nothing reaches a relay before then. What's waiting shows as "Scheduled (n)" under the composer and on your profile, each with Cancel (a kind 5 to the notifier, so it never goes out); one cancelled on another device leaves the list at the next status check
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMan6TUeqTyZB8eXLGymUY
-rw-r--r--src/features/messages.js153
-rw-r--r--src/i18n.js7
-rw-r--r--src/locales/ar.json9
-rw-r--r--src/locales/bn.json9
-rw-r--r--src/locales/de.json9
-rw-r--r--src/locales/es.json9
-rw-r--r--src/locales/fr.json9
-rw-r--r--src/locales/hi.json9
-rw-r--r--src/locales/id.json9
-rw-r--r--src/locales/it.json9
-rw-r--r--src/locales/ja.json9
-rw-r--r--src/locales/ko.json9
-rw-r--r--src/locales/mr.json9
-rw-r--r--src/locales/pt.json9
-rw-r--r--src/locales/ru.json9
-rw-r--r--src/locales/sw.json9
-rw-r--r--src/locales/ta.json9
-rw-r--r--src/locales/te.json9
-rw-r--r--src/locales/tr.json9
-rw-r--r--src/locales/ur.json9
-rw-r--r--src/locales/vi.json9
-rw-r--r--src/locales/zh.json9
-rw-r--r--src/style.css6
-rw-r--r--tools/scheduled-post-test.js99
24 files changed, 408 insertions, 37 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 76d8c4f..cdb9aea 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -12356,12 +12356,24 @@ export function messagesFeature(ctx) {
// at the top of the feed before any signing or relay round-trip (a remote
// signer alone can take seconds), swapped for the signed event on success
// and withdrawn on failure.
+ // the hashtags in the text as t tags (NIP-24), so topic feeds and other
+ // clients' searches find the post — up to the count past which a post
+ // reads as hashtag spam
+ const postHashtags = (text) => [...new Set([...String(text || '').matchAll(/(^|[^\w#/&])#([\p{L}\p{N}_]{2,64})(?![\w])/gu)].map((m) => normTopic(m[2])))].slice(0, HASHTAG_MAX);
+ function postTags(text, media, me) {
+ // NIP-92: what we uploaded, described, so clients needn't sniff the URL
+ const imeta = media.filter((m) => m && m.url).map(mediaImeta);
+ // NIP-18 quote: the reference is already in the text; the tags let a
+ // client render the quoted post inline and tell its author
+ const q = ui.quoteOf && text.includes(ui.quoteOf.id.slice(0, 8))
+ ? [] : ui.quoteOf ? [['q', ui.quoteOf.id], ['p', ui.quoteOf.pubkey]] : [];
+ // the people it mentions are told (NIP-27 p tags)
+ const mentions = mentionedPks(text).filter((pk) => pk !== me && !q.some((x) => x[0] === 'p' && x[1] === pk)).map((pk) => ['p', pk]);
+ return [...imeta, ...q, ...mentions, ...postHashtags(text).map((x) => ['t', x]), CLIENT_TAG];
+ }
async function publishPost(text, media = []) {
const id = await requireIdentity();
- // the hashtags in the text as t tags (NIP-24), so topic feeds and other
- // clients' searches find the post — up to the count past which a post
- // reads as hashtag spam
- const hashtags = [...new Set([...String(text || '').matchAll(/(^|[^\w#/&])#([\p{L}\p{N}_]{2,64})(?![\w])/gu)].map((m) => normTopic(m[2])))].slice(0, HASHTAG_MAX);
+ const hashtags = postHashtags(text);
const temp = {
id: 'pending:' + Math.random().toString(36).slice(2),
pubkey: id.pubkey, content: text, created_at: Math.floor(Date.now() / 1000),
@@ -12380,15 +12392,7 @@ export function messagesFeature(ctx) {
for (const c of feeds) c.notes = [temp, ...c.notes];
render();
try {
- // NIP-92: what we uploaded, described, so clients needn't sniff the URL
- const imeta = media.filter((m) => m && m.url).map(mediaImeta);
- // NIP-18 quote: the reference is already in the text; the tags let a
- // client render the quoted post inline and tell its author
- const q = ui.quoteOf && text.includes(ui.quoteOf.id.slice(0, 8))
- ? [] : ui.quoteOf ? [['q', ui.quoteOf.id], ['p', ui.quoteOf.pubkey]] : [];
- // the people it mentions are told (NIP-27 p tags)
- const mentions = mentionedPks(text).filter((pk) => pk !== id.pubkey && !q.some((x) => x[0] === 'p' && x[1] === pk)).map((pk) => ['p', pk]);
- const partial = { kind: 1, content: text, created_at: temp.created_at, tags: [...imeta, ...q, ...mentions, ...temp.tags, CLIENT_TAG] };
+ const partial = { kind: 1, content: text, created_at: temp.created_at, tags: postTags(text, media, id.pubkey) };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const relays = [...new Set([...(await notesRelays(id.pubkey)), ...wallet.nostrRelays()])];
const ok = await publishOn(relays, evt);
@@ -12404,6 +12408,93 @@ export function messagesFeature(ctx) {
}
}
+ // ---- scheduled posts -----------------------------------------------------
+ // Signed now, with created_at = when it should appear, and handed to the
+ // notifier, which holds it and publishes it then (relays refuse notes from
+ // the future). Signing now means a remote signer is asked while you're
+ // here, and the note that goes out is exactly the one you wrote. This
+ // device remembers what it scheduled, to show and cancel; the post itself
+ // turns up in the feeds once it's out.
+ const SCHED_MIN_AHEAD = 120;
+ async function schedulePost(text, media, at) {
+ const id = await requireIdentity();
+ if (!(at > Math.floor(Date.now() / 1000) + SCHED_MIN_AHEAD)) throw new Error(t('schedTooSoon'));
+ const partial = { kind: 1, content: text, created_at: at, tags: postTags(text, media, id.pubkey) };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ const relays = [...new Set([...(await notesRelays(id.pubkey)), ...wallet.nostrRelays()])];
+ const res = await fetch(`${NOTIFIER}/schedule`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ event: evt, relays }),
+ }).then((r) => r.json()).catch(() => ({ error: t('msgSendFailed') }));
+ if (!res.ok) throw new Error(res.error || t('msgSendFailed'));
+ ui.quoteOf = null;
+ const s = st();
+ s.scheduled ||= {};
+ s.scheduled[evt.id] = { at, text, pubkey: id.pubkey };
+ save(s);
+ render();
+ return evt;
+ }
+ // what this account has waiting, soonest first; anything past its time
+ // has gone out (or the notifier gave up) and leaves the list
+ function scheduledPosts() {
+ const s = st(), me = mePk(), now = Math.floor(Date.now() / 1000);
+ let dropped = false;
+ for (const [k, v] of Object.entries(s.scheduled || {})) if (v.at < now - 60) { delete s.scheduled[k]; dropped = true; }
+ if (dropped) save(s);
+ return Object.entries(s.scheduled || {}).filter(([, v]) => v.pubkey === me)
+ .map(([id, v]) => ({ id, ...v })).sort((a, b) => a.at - b.at);
+ }
+ // once a session, ask which are still held: one cancelled on another
+ // device is gone from the notifier, so it goes from here too
+ let schedChecked = false;
+ function checkScheduled(list) {
+ if (schedChecked || !list.length) return;
+ schedChecked = true;
+ fetch(`${NOTIFIER}/schedule/status?ids=${list.map((x) => x.id).join(',')}`).then((r) => r.json()).then((st_) => {
+ const s = st();
+ let n = 0;
+ for (const [k, v] of Object.entries(st_ || {})) if (v === 'gone' && s.scheduled?.[k]) { delete s.scheduled[k]; n++; }
+ if (n) { save(s); render(); }
+ }).catch(() => {});
+ }
+ async function cancelScheduled(item) {
+ const id = await requireIdentity();
+ const partial = { kind: 5, content: '', created_at: Math.floor(Date.now() / 1000), tags: [['e', item.id]] };
+ const del = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ const res = await fetch(`${NOTIFIER}/schedule/cancel`, {
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ event: del }),
+ }).then((r) => r.json()).catch(() => ({}));
+ if (!res.ok) throw new Error(res.error || t('msgSendFailed'));
+ const s = st();
+ delete s.scheduled?.[item.id];
+ save(s);
+ render();
+ }
+ const schedWhen = (at) => new Date(at * 1000).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+ // <input type=datetime-local> speaks local wall-clock time
+ const localInput = (ms) => { const d = new Date(ms); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 16); };
+ function scheduledList() {
+ const list = scheduledPosts();
+ checkScheduled(list);
+ if (!list.length) return null;
+ return h('div', { class: 'card col sched-list', 'data-key': 'sched-list', style: 'gap:8px' },
+ h('div', { class: 'small muted' }, t('schedListTitle', { n: list.length })),
+ ...list.map((x) => h('div', { class: 'row gap6 sched-item', 'data-key': 'sched:' + x.id, style: 'align-items:flex-start' },
+ h('div', { class: 'col grow', style: 'min-width:0;gap:2px' },
+ h('div', { class: 'small', style: 'font-weight:600' }, '\u{1F552} ' + schedWhen(x.at)),
+ h('div', { class: 'small muted', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, x.text)),
+ h('button', {
+ class: 'btn-ghost btn-sm', disabled: ui.schedCancelling === x.id,
+ onClick: async () => {
+ ui.schedCancelling = x.id; render();
+ try { await cancelScheduled(x); toast(t('schedCancelled')); }
+ catch (e) { if (!e.silent) toast(e.message || String(e)); }
+ ui.schedCancelling = null; render();
+ },
+ }, ui.schedCancelling === x.id ? h('span', { class: 'spinner sm' }) : t('cancel')))));
+ }
+
async function publishReply(c, s, text) {
const id = await requireIdentity();
const target = c.replies.find((e) => e.id === s.focusId) || c.root || s.seed;
@@ -13153,6 +13244,7 @@ export function messagesFeature(ctx) {
// the half-written post back, composer open. Posting clears
// it; Cancel just closes the composer and keeps the text.
postComposer(),
+ ui.profCompose == null ? scheduledList() : null,
// the non-destructive way out: add another identity (Nostr,
// passkey, Google) next to this one and switch between them
// on the Accounts screen — logout stays for actually leaving
@@ -13333,6 +13425,7 @@ export function messagesFeature(ctx) {
ui.profCompose = null;
ui.postMedia = [];
ui.postPreview = false;
+ ui.postSchedule = null;
discardDraft(POST_DRAFT);
render();
}
@@ -13428,11 +13521,34 @@ export function messagesFeature(ctx) {
mentionList('.post-input'),
ui.postPreview ? draftPreview(text) : null,
mediaBar(),
+ ui.postSchedule != null ? h('label', { class: 'row gap6 sched-pick', style: 'align-items:center' },
+ h('span', { class: 'small muted' }, t('schedAt')),
+ h('input', {
+ type: 'datetime-local', class: 'grow', value: ui.postSchedule,
+ min: localInput(Date.now() + SCHED_MIN_AHEAD * 1000), max: localInput(Date.now() + 90 * 86400_000),
+ onInput: (ev) => { ui.postSchedule = ev.target.value; },
+ })) : null,
h('div', { class: 'row gap6 wrap post-actions' },
- h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading, onClick: async () => {
+ h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading || !!ui.postScheduling, onClick: async () => {
const body = composeText().trim();
if (!body) return;
const media = (ui.postMedia || []).filter((m) => body.includes(m.url));
+ if (ui.postSchedule != null) {
+ // nothing goes on screen yet, so the composer stays open until
+ // the notifier has it (a signer may ask first)
+ const at = Math.floor(new Date(ui.postSchedule).getTime() / 1000);
+ ui.postScheduling = true; render();
+ try {
+ await schedulePost(body, media, at);
+ ui.profCompose = null; ui.postMedia = []; ui.postSchedule = null;
+ setDraft(POST_DRAFT, '');
+ const inp = document.querySelector('.post-input');
+ if (inp) inp.value = '';
+ toast(t('schedDone', { when: schedWhen(at) }));
+ } catch (e) { if (!e.silent) toast(e.message || String(e)); }
+ ui.postScheduling = false; render();
+ return;
+ }
// the post is on screen instantly (publishPost is optimistic) —
// close the composer now; a failure reopens it with the text intact
ui.profCompose = null;
@@ -13446,14 +13562,21 @@ export function messagesFeature(ctx) {
if (!e.silent) toast(e.message || String(e));
render();
}
- } }, t('profPostBtn')),
+ } }, ui.postScheduling ? h('span', { class: 'spinner sm' }) : t(ui.postSchedule != null ? 'schedBtn' : 'profPostBtn')),
h('input', {
type: 'file', id: 'post-file', accept: 'image/*,video/*', style: 'display:none',
onChange: async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; await attachMedia(f); },
}),
h('div', { class: 'row gap6 composer-options' },
previewBtn(!!ui.postPreview, () => { ui.postPreview = !ui.postPreview; render(); }),
+ h('button', {
+ type: 'button', class: 'btn-ghost btn-sm', title: t('schedToggle'), 'aria-label': t('schedToggle'),
+ 'aria-pressed': String(ui.postSchedule != null),
+ // an hour from now, on the next quarter hour
+ onClick: () => { ui.postSchedule = ui.postSchedule != null ? null : localInput(Math.ceil((Date.now() + 3600_000) / 900_000) * 900_000); render(); },
+ }, '\u{1F552}'),
h('button', { class: 'btn-ghost btn-sm', onClick: requestCancelPost }, t('cancel')))),
+ scheduledList(),
postCancelWarning(),
gifs.sheet('post', (g) => {
const cur = composeText().replace(/\s+$/, '');
diff --git a/src/i18n.js b/src/i18n.js
index 5695c4a..f549316 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -668,6 +668,13 @@ const STR = {
profComposePh: "What's on your mind?",
profPostBtn: 'Post',
profPosted: 'Posted',
+ schedToggle: 'Schedule for later',
+ schedAt: 'Post at',
+ schedBtn: 'Schedule',
+ schedDone: 'Scheduled for {when}',
+ schedTooSoon: 'Pick a time at least two minutes from now',
+ schedListTitle: 'Scheduled ({n})',
+ schedCancelled: 'Scheduled post cancelled',
publicChatTitle: '{name} community',
publicChatDesc: 'Live chat: read along, sign in to join the conversation',
publicChatSignIn: 'Sign in to chat',
diff --git a/src/locales/ar.json b/src/locales/ar.json
index 2d7bd17..a79178b 100644
--- a/src/locales/ar.json
+++ b/src/locales/ar.json
@@ -934,5 +934,12 @@
"arkRenewViewTx": "عرض معاملة الجولة",
"pagerFetchingVideo": "جارٍ جلب الفيديو التالي…",
"pagerFetchingPictures": "جارٍ جلب المزيد من الصور…",
- "pagerCaughtUp": "هذا كل شيء الآن"
+ "pagerCaughtUp": "هذا كل شيء الآن",
+ "schedToggle": "جدولة لوقت لاحق",
+ "schedAt": "النشر في",
+ "schedBtn": "جدولة",
+ "schedDone": "مجدول في {when}",
+ "schedTooSoon": "اختر وقتًا بعد دقيقتين على الأقل من الآن",
+ "schedListTitle": "مجدولة ({n})",
+ "schedCancelled": "أُلغي المنشور المجدول"
}
diff --git a/src/locales/bn.json b/src/locales/bn.json
index 82bbd7f..de06f4c 100644
--- a/src/locales/bn.json
+++ b/src/locales/bn.json
@@ -934,5 +934,12 @@
"arkRenewViewTx": "রাউন্ডের লেনদেন দেখুন",
"pagerFetchingVideo": "পরের ভিডিও আনা হচ্ছে…",
"pagerFetchingPictures": "আরও ছবি আনা হচ্ছে…",
- "pagerCaughtUp": "আপাতত এটুকুই"
+ "pagerCaughtUp": "আপাতত এটুকুই",
+ "schedToggle": "পরে পোস্ট করার সময় ঠিক করুন",
+ "schedAt": "পোস্টের সময়",
+ "schedBtn": "সময় ঠিক করুন",
+ "schedDone": "{when}-এ নির্ধারিত",
+ "schedTooSoon": "এখন থেকে অন্তত দুই মিনিট পরের সময় বেছে নিন",
+ "schedListTitle": "নির্ধারিত ({n})",
+ "schedCancelled": "নির্ধারিত পোস্ট বাতিল হয়েছে"
}
diff --git a/src/locales/de.json b/src/locales/de.json
index 0f0464e..496ebe1 100644
--- a/src/locales/de.json
+++ b/src/locales/de.json
@@ -934,5 +934,12 @@
"arkRenewViewTx": "Transaktion der Runde ansehen",
"pagerFetchingVideo": "Nächstes Video wird geladen …",
"pagerFetchingPictures": "Weitere Bilder werden geladen …",
- "pagerCaughtUp": "Das ist vorerst alles"
+ "pagerCaughtUp": "Das ist vorerst alles",
+ "schedToggle": "Für später planen",
+ "schedAt": "Posten am",
+ "schedBtn": "Planen",
+ "schedDone": "Geplant für {when}",
+ "schedTooSoon": "Wähle eine Zeit mindestens zwei Minuten ab jetzt",
+ "schedListTitle": "Geplant ({n})",
+ "schedCancelled": "Geplanter Beitrag abgebrochen"
}
diff --git a/src/locales/es.json b/src/locales/es.json
index f7a74b8..53af9de 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -934,5 +934,12 @@
"arkRenewViewTx": "Ver la transacción de la ronda",
"pagerFetchingVideo": "Cargando el siguiente vídeo…",
"pagerFetchingPictures": "Cargando más imágenes…",
- "pagerCaughtUp": "Eso es todo por ahora"
+ "pagerCaughtUp": "Eso es todo por ahora",
+ "schedToggle": "Programar para más tarde",
+ "schedAt": "Publicar el",
+ "schedBtn": "Programar",
+ "schedDone": "Programado para {when}",
+ "schedTooSoon": "Elige una hora al menos dos minutos a partir de ahora",
+ "schedListTitle": "Programadas ({n})",
+ "schedCancelled": "Publicación programada cancelada"
}
diff --git a/src/locales/fr.json b/src/locales/fr.json
index 650a208..f2e159e 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -934,5 +934,12 @@
"arkRenewViewTx": "Voir la transaction du tour",
"pagerFetchingVideo": "Chargement de la vidéo suivante…",
"pagerFetchingPictures": "Chargement d’autres images…",
- "pagerCaughtUp": "C’est tout pour le moment"
+ "pagerCaughtUp": "C’est tout pour le moment",
+ "schedToggle": "Programmer pour plus tard",
+ "schedAt": "Publier le",
+ "schedBtn": "Programmer",
+ "schedDone": "Programmé pour {when}",
+ "schedTooSoon": "Choisis une heure au moins deux minutes après maintenant",
+ "schedListTitle": "Programmés ({n})",
+ "schedCancelled": "Publication programmée annulée"
}
diff --git a/src/locales/hi.json b/src/locales/hi.json
index c3245c7..42c85e8 100644
--- a/src/locales/hi.json
+++ b/src/locales/hi.json
@@ -934,5 +934,12 @@
"arkRenewViewTx": "राउंड का लेनदेन देखें",
"pagerFetchingVideo": "अगला वीडियो लाया जा रहा है…",
"pagerFetchingPictures": "और तस्वीरें लाई जा रही हैं…",
- "pagerCaughtUp": "अभी के लिए बस इतना ही"
+ "pagerCaughtUp": "अभी के लिए बस इतना ही",
+ "schedToggle": "बाद के लिए शेड्यूल करें",
+ "schedAt": "पोस्ट करने का समय",
+ "schedBtn": "शेड्यूल करें",
+ "schedDone": "{when} के लिए शेड्यूल किया गया",
+ "schedTooSoon": "अभी से कम से कम दो मिनट बाद का समय चुनें",
+ "schedListTitle": "शेड्यूल किए गए ({n})",
+ "schedCancelled": "शेड्यूल की गई पोस्ट रद्द हुई"
}
diff --git a/src/locales/id.json b/src/locales/id.json
index a2c08e4..fc807ec 100644
--- a/src/locales/id.json
+++ b/src/locales/id.json
@@ -271,5 +271,12 @@
"pagerCaughtUp": "Sementara itu saja",
"onbGiftNameTitle": "Sekarang pilih nama pengguna",
"onbGiftNameBody": "Klaim sebuah nama dan dapatkan alamat pembayaran sendiri, agar orang bisa mengirimimu bitcoin hanya dengan namamu. Lalu pilih foto dan sampul.",
- "namesChangeTo": "Ganti ke {addr}"
+ "namesChangeTo": "Ganti ke {addr}",
+ "schedToggle": "Jadwalkan untuk nanti",
+ "schedAt": "Posting pada",
+ "schedBtn": "Jadwalkan",
+ "schedDone": "Dijadwalkan untuk {when}",
+ "schedTooSoon": "Pilih waktu setidaknya dua menit dari sekarang",
+ "schedListTitle": "Terjadwal ({n})",
+ "schedCancelled": "Postingan terjadwal dibatalkan"
}
diff --git a/src/locales/it.json b/src/locales/it.json
index 37407d4..f9ffcb6 100644
--- a/src/locales/it.json
+++ b/src/locales/it.json
@@ -1468,5 +1468,12 @@
"lockPwOrPassword": "oppure imposta una password:",
"pagerFetchingVideo": "Caricamento del prossimo video…",
"pagerFetchingPictures": "Caricamento di altre immagini…",
- "pagerCaughtUp": "Per ora è tutto"
+ "pagerCaughtUp": "Per ora è tutto",
+ "schedToggle": "Programma per dopo",
+ "schedAt": "Pubblica il",
+ "schedBtn": "Programma",
+ "schedDone": "Programmato per {when}",
+ "schedTooSoon": "Scegli un orario almeno due minuti da adesso",
+ "schedListTitle": "Programmati ({n})",
+ "schedCancelled": "Post programmato annullato"
}
diff --git a/src/locales/ja.json b/src/locales/ja.json
index 32c1b07..26b7d1d 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -934,5 +934,12 @@
"arkRenewViewTx": "ラウンドのトランザクションを見る",
"pagerFetchingVideo": "次の動画を取得中…",
"pagerFetchingPictures": "画像をさらに取得中…",
- "pagerCaughtUp": "今のところ以上です"
+ "pagerCaughtUp": "今のところ以上です",
+ "schedToggle": "あとで投稿する",
+ "schedAt": "投稿日時",
+ "schedBtn": "予約する",
+ "schedDone": "{when} に予約しました",
+ "schedTooSoon": "今から2分以上先の時刻を選んでください",
+ "schedListTitle": "予約済み({n})",
+ "schedCancelled": "予約投稿を取り消しました"
}
diff --git a/src/locales/ko.json b/src/locales/ko.json
index 703a137..b697c72 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -933,5 +933,12 @@
"arkRenewViewTx": "라운드 거래 보기",
"pagerFetchingVideo": "다음 동영상을 가져오는 중…",
"pagerFetchingPictures": "사진을 더 가져오는 중…",
- "pagerCaughtUp": "지금은 여기까지예요"
+ "pagerCaughtUp": "지금은 여기까지예요",
+ "schedToggle": "나중에 게시 예약",
+ "schedAt": "게시 시간",
+ "schedBtn": "예약",
+ "schedDone": "{when}에 예약됨",
+ "schedTooSoon": "지금부터 최소 2분 뒤의 시간을 고르세요",
+ "schedListTitle": "예약됨 ({n})",
+ "schedCancelled": "예약 게시물이 취소되었습니다"
}
diff --git a/src/locales/mr.json b/src/locales/mr.json
index 0d86d70..3bd67c1 100644
--- a/src/locales/mr.json
+++ b/src/locales/mr.json
@@ -271,5 +271,12 @@
"pagerCaughtUp": "सध्या एवढेच",
"onbGiftNameTitle": "आता एक युजरनेम निवडा",
"onbGiftNameBody": "एक नाव घ्या आणि स्वतःचा पेमेंट पत्ता मिळवा, म्हणजे लोक फक्त तुमच्या नावाने तुम्हाला बिटकॉइन पाठवू शकतील. मग एक फोटो आणि कव्हर निवडा.",
- "namesChangeTo": "{addr} मध्ये बदला"
+ "namesChangeTo": "{addr} मध्ये बदला",
+ "schedToggle": "नंतरसाठी वेळ ठरवा",
+ "schedAt": "पोस्टची वेळ",
+ "schedBtn": "वेळ ठरवा",
+ "schedDone": "{when} साठी ठरवले",
+ "schedTooSoon": "आतापासून किमान दोन मिनिटांनंतरची वेळ निवडा",
+ "schedListTitle": "ठरवलेल्या ({n})",
+ "schedCancelled": "ठरवलेली पोस्ट रद्द झाली"
}
diff --git a/src/locales/pt.json b/src/locales/pt.json
index ddbcdb8..8e3afad 100644
--- a/src/locales/pt.json
+++ b/src/locales/pt.json
@@ -934,5 +934,12 @@
"arkRenewViewTx": "Ver a transação da rodada",
"pagerFetchingVideo": "Buscando o próximo vídeo…",
"pagerFetchingPictures": "Buscando mais imagens…",
- "pagerCaughtUp": "Por enquanto é só isso"
+ "pagerCaughtUp": "Por enquanto é só isso",
+ "schedToggle": "Agendar para depois",
+ "schedAt": "Publicar em",
+ "schedBtn": "Agendar",
+ "schedDone": "Agendado para {when}",
+ "schedTooSoon": "Escolha um horário pelo menos dois minutos a partir de agora",
+ "schedListTitle": "Agendados ({n})",
+ "schedCancelled": "Publicação agendada cancelada"
}
diff --git a/src/locales/ru.json b/src/locales/ru.json
index a58be9a..0d010af 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -934,5 +934,12 @@
"arkRenewViewTx": "Посмотреть транзакцию раунда",
"pagerFetchingVideo": "Загружаем следующее видео…",
"pagerFetchingPictures": "Загружаем ещё картинки…",
- "pagerCaughtUp": "Пока это всё"
+ "pagerCaughtUp": "Пока это всё",
+ "schedToggle": "Запланировать",
+ "schedAt": "Опубликовать",
+ "schedBtn": "Запланировать",
+ "schedDone": "Запланировано на {when}",
+ "schedTooSoon": "Выберите время не раньше чем через две минуты",
+ "schedListTitle": "Запланировано ({n})",
+ "schedCancelled": "Запланированный пост отменён"
}
diff --git a/src/locales/sw.json b/src/locales/sw.json
index 2749871..6aecfe6 100644
--- a/src/locales/sw.json
+++ b/src/locales/sw.json
@@ -271,5 +271,12 @@
"pagerCaughtUp": "Hayo ndiyo yote kwa sasa",
"onbGiftNameTitle": "Sasa chagua jina la mtumiaji",
"onbGiftNameBody": "Chukua jina upate anwani yako ya malipo, ili watu wakutumie bitcoin kwa jina lako tu. Kisha chagua picha na jalada.",
- "namesChangeTo": "Badilisha kuwa {addr}"
+ "namesChangeTo": "Badilisha kuwa {addr}",
+ "schedToggle": "Panga kwa baadaye",
+ "schedAt": "Chapisha saa",
+ "schedBtn": "Panga",
+ "schedDone": "Imepangwa {when}",
+ "schedTooSoon": "Chagua muda angalau dakika mbili kuanzia sasa",
+ "schedListTitle": "Zilizopangwa ({n})",
+ "schedCancelled": "Chapisho lililopangwa limeghairiwa"
}
diff --git a/src/locales/ta.json b/src/locales/ta.json
index 65f405f..066e275 100644
--- a/src/locales/ta.json
+++ b/src/locales/ta.json
@@ -271,5 +271,12 @@
"pagerCaughtUp": "இப்போதைக்கு இவ்வளவுதான்",
"onbGiftNameTitle": "இப்போது ஒரு பயனர்பெயரைத் தேர்ந்தெடுங்கள்",
"onbGiftNameBody": "ஒரு பெயரைப் பெற்று உங்கள் சொந்த பணம் பெறும் முகவரியைப் பெறுங்கள்; உங்கள் பெயரை மட்டும் வைத்தே மற்றவர்கள் பிட்காயின் அனுப்பலாம். பிறகு ஒரு படத்தையும் அட்டைப்படத்தையும் தேர்ந்தெடுங்கள்.",
- "namesChangeTo": "{addr} ஆக மாற்று"
+ "namesChangeTo": "{addr} ஆக மாற்று",
+ "schedToggle": "பின்னர் இடுகையிட திட்டமிடு",
+ "schedAt": "இடுகை நேரம்",
+ "schedBtn": "திட்டமிடு",
+ "schedDone": "{when} க்கு திட்டமிடப்பட்டது",
+ "schedTooSoon": "இப்போதிலிருந்து குறைந்தது இரண்டு நிமிடங்களுக்குப் பிந்தைய நேரத்தைத் தேர்ந்தெடுக்கவும்",
+ "schedListTitle": "திட்டமிடப்பட்டவை ({n})",
+ "schedCancelled": "திட்டமிட்ட இடுகை ரத்து செய்யப்பட்டது"
}
diff --git a/src/locales/te.json b/src/locales/te.json
index 2ff97d3..88389fd 100644
--- a/src/locales/te.json
+++ b/src/locales/te.json
@@ -269,5 +269,12 @@
"pagerCaughtUp": "ప్రస్తుతానికి ఇంతే",
"onbGiftNameTitle": "ఇప్పుడు ఒక యూజర్‌నేమ్ ఎంచుకోండి",
"onbGiftNameBody": "ఒక పేరు తీసుకుని మీ సొంత చెల్లింపు చిరునామా పొందండి, అప్పుడు మీ పేరుతోనే ఎవరైనా మీకు బిట్‌కాయిన్ పంపవచ్చు. తర్వాత ఒక ఫోటో, ఒక కవర్ ఎంచుకోండి.",
- "namesChangeTo": "{addr}కి మార్చు"
+ "namesChangeTo": "{addr}కి మార్చు",
+ "schedToggle": "తర్వాత కోసం షెడ్యూల్ చేయండి",
+ "schedAt": "పోస్ట్ సమయం",
+ "schedBtn": "షెడ్యూల్ చేయండి",
+ "schedDone": "{when}కి షెడ్యూల్ చేయబడింది",
+ "schedTooSoon": "ఇప్పటి నుండి కనీసం రెండు నిమిషాల తర్వాతి సమయాన్ని ఎంచుకోండి",
+ "schedListTitle": "షెడ్యూల్ చేసినవి ({n})",
+ "schedCancelled": "షెడ్యూల్ చేసిన పోస్ట్ రద్దయింది"
}
diff --git a/src/locales/tr.json b/src/locales/tr.json
index d3538ef..8d08b68 100644
--- a/src/locales/tr.json
+++ b/src/locales/tr.json
@@ -933,5 +933,12 @@
"arkRenewViewTx": "Turun işlemini görüntüle",
"pagerFetchingVideo": "Sonraki video getiriliyor…",
"pagerFetchingPictures": "Daha fazla resim getiriliyor…",
- "pagerCaughtUp": "Şimdilik bu kadar"
+ "pagerCaughtUp": "Şimdilik bu kadar",
+ "schedToggle": "Sonrası için zamanla",
+ "schedAt": "Paylaşım zamanı",
+ "schedBtn": "Zamanla",
+ "schedDone": "{when} için zamanlandı",
+ "schedTooSoon": "Şu andan en az iki dakika sonrasını seç",
+ "schedListTitle": "Zamanlanmış ({n})",
+ "schedCancelled": "Zamanlanmış gönderi iptal edildi"
}
diff --git a/src/locales/ur.json b/src/locales/ur.json
index f83299a..d579fc3 100644
--- a/src/locales/ur.json
+++ b/src/locales/ur.json
@@ -271,5 +271,12 @@
"pagerCaughtUp": "فی الحال بس اتنا ہی",
"onbGiftNameTitle": "اب ایک یوزرنیم چنیں",
"onbGiftNameBody": "ایک نام لیں اور اپنا ادائیگی کا پتہ پائیں، تاکہ لوگ صرف آپ کے نام سے آپ کو بٹ کوائن بھیج سکیں۔ پھر ایک تصویر اور ایک کور چنیں۔",
- "namesChangeTo": "{addr} میں بدلیں"
+ "namesChangeTo": "{addr} میں بدلیں",
+ "schedToggle": "بعد کے لیے شیڈول کریں",
+ "schedAt": "پوسٹ کا وقت",
+ "schedBtn": "شیڈول کریں",
+ "schedDone": "{when} کے لیے شیڈول",
+ "schedTooSoon": "ابھی سے کم از کم دو منٹ بعد کا وقت چنیں",
+ "schedListTitle": "شیڈول شدہ ({n})",
+ "schedCancelled": "شیڈول شدہ پوسٹ منسوخ ہو گئی"
}
diff --git a/src/locales/vi.json b/src/locales/vi.json
index a24391b..1923062 100644
--- a/src/locales/vi.json
+++ b/src/locales/vi.json
@@ -271,5 +271,12 @@
"pagerCaughtUp": "Tạm thời chỉ có vậy",
"onbGiftNameTitle": "Giờ hãy chọn tên người dùng",
"onbGiftNameBody": "Nhận một cái tên và địa chỉ thanh toán của riêng bạn, để mọi người gửi bitcoin cho bạn chỉ bằng tên. Sau đó chọn ảnh đại diện và ảnh bìa.",
- "namesChangeTo": "Đổi thành {addr}"
+ "namesChangeTo": "Đổi thành {addr}",
+ "schedToggle": "Hẹn giờ đăng",
+ "schedAt": "Đăng lúc",
+ "schedBtn": "Hẹn giờ",
+ "schedDone": "Đã hẹn lúc {when}",
+ "schedTooSoon": "Chọn thời điểm ít nhất hai phút kể từ bây giờ",
+ "schedListTitle": "Đã hẹn ({n})",
+ "schedCancelled": "Đã huỷ bài hẹn giờ"
}
diff --git a/src/locales/zh.json b/src/locales/zh.json
index c75e801..0f70b11 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -934,5 +934,12 @@
"arkRenewViewTx": "查看该轮交易",
"pagerFetchingVideo": "正在获取下一个视频…",
"pagerFetchingPictures": "正在获取更多图片…",
- "pagerCaughtUp": "暂时就这些了"
+ "pagerCaughtUp": "暂时就这些了",
+ "schedToggle": "定时发布",
+ "schedAt": "发布时间",
+ "schedBtn": "定时",
+ "schedDone": "已定时于 {when}",
+ "schedTooSoon": "请选择至少两分钟之后的时间",
+ "schedListTitle": "已定时({n})",
+ "schedCancelled": "已取消定时帖子"
}
diff --git a/src/style.css b/src/style.css
index bf61f6a..f26fac3 100644
--- a/src/style.css
+++ b/src/style.css
@@ -251,7 +251,7 @@ button:disabled { opacity: .5; cursor: not-allowed; }
/* ---- inputs ---- */
label.field { display: block; }
.field .lab { font-size: 13px; font-weight: 600; color: var(--muted); margin-bottom: 6px; display: block; }
-input[type=text], input[type=search], input[type=password], input[type=number], textarea, select {
+input[type=text], input[type=search], input[type=password], input[type=number], input[type=datetime-local], textarea, select {
width: 100%; font: inherit; color: var(--ink); background: var(--paper);
border: 1px solid var(--line-strong); border-radius: 11px; padding: 11px 13px;
}
@@ -1261,6 +1261,8 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.chat-avatar.header-ava { width: 30px; height: 30px; font-size: 11px; }
.composer-options { margin-inline-start: auto; flex-shrink: 0; }
.compose-preview-toggle[aria-pressed="true"] { background: var(--accent-soft); color: var(--ink); }
+.post-actions button[aria-pressed="true"]:not(.compose-preview-toggle) { background: var(--accent-soft); color: var(--ink); }
+.sched-pick input { min-width: 0; }
/* the post composer: Preview and Cancel stand as tall as Post and the
attach square beside them, in the same type */
.post-actions .composer-options > button { padding: 11px 16px; font-size: inherit; border-radius: 11px; min-height: 44px; }
@@ -1354,7 +1356,7 @@ button, .btn { transition: filter .12s, background .15s, border-color .15s, box-
.chat-avatar.clickable:hover { transform: scale(1.07); }
.header-avatar .chat-avatar { transition: transform .15s ease; }
.header-avatar:hover .chat-avatar { transform: scale(1.08); }
-input[type=text], input[type=search], input[type=password], input[type=number], textarea, select { transition: border-color .15s, box-shadow .15s; }
+input[type=text], input[type=search], input[type=password], input[type=number], input[type=datetime-local], textarea, select { transition: border-color .15s, box-shadow .15s; }
.settings-nav::after { transition: transform .18s ease, color .18s; }
.settings-nav:hover::after { transform: translateX(3px); color: var(--ink); }
diff --git a/tools/scheduled-post-test.js b/tools/scheduled-post-test.js
new file mode 100644
index 0000000..546537c
--- /dev/null
+++ b/tools/scheduled-post-test.js
@@ -0,0 +1,99 @@
+// Scheduled posts: the composer's clock button picks a time; Schedule signs
+// the note with that created_at and hands it to the notifier (POST
+// nwcpush /schedule), nothing goes to a relay, the composer closes and the
+// post waits in a "Scheduled" list, where Cancel sends a kind 5 for it to
+// /schedule/cancel. The notifier is stubbed.
+// Run: bun tools/scheduled-post-test.js
+import puppeteer from './puppeteer.js';
+import { verifyEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5316, fetch: (req) => { const p = new URL(req.url).pathname; if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); return new Response(html, { headers: { 'content-type': 'text/html' } }); } });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => {
+ window.__published = [];
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') { window.__published.push(m[1]); setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0); return; } } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+});
+const held = new Map();
+const calls = [];
+await page.setRequestInterception(true);
+page.on('request', (r) => {
+ const u = new URL(r.url());
+ if (u.host !== 'nwcpush.coinos.io' || !u.pathname.startsWith('/schedule')) return r.continue();
+ const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type' };
+ if (r.method() === 'OPTIONS') return r.respond({ status: 200, headers: cors, body: '' });
+ const body = r.postData() ? JSON.parse(r.postData()) : null;
+ calls.push({ path: u.pathname, body });
+ let res = { ok: true };
+ if (u.pathname === '/schedule') held.set(body.event.id, body.event);
+ if (u.pathname === '/schedule/cancel') { let n = 0; for (const [k, id] of body.event.tags) if (k === 'e' && held.delete(id)) n++; res = { ok: true, cancelled: n }; }
+ if (u.pathname === '/schedule/status') res = Object.fromEntries(u.searchParams.get('ids').split(',').map((id) => [id, held.has(id) ? 'pending' : 'gone']));
+ r.respond({ status: 200, headers: { ...cors, 'content-type': 'application/json' }, body: JSON.stringify(res) });
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, ms = 15000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; };
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5316/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ await click('skip verification');
+ await waitText('receive', 20000);
+
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000);
+ await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ await waitFor(() => !!document.querySelector('.post-input'), 8000);
+ await page.focus('.post-input');
+ await page.keyboard.type('later, #coinos', { delay: 10 });
+ await page.evaluate(() => document.querySelector('.post-actions button[aria-label="Schedule for later"]').click());
+ check('the clock button shows a time field, about an hour ahead', await waitFor(() => !!document.querySelector('.sched-pick input[type=datetime-local]'), 5000));
+ const shown = await page.$eval('.sched-pick input', (e) => e.value);
+ const shownAt = new Date(shown).getTime();
+ check('...on a quarter hour', shownAt - Date.now() > 3500_000 && shownAt - Date.now() < 4500_000 && new Date(shownAt).getMinutes() % 15 === 0, shown);
+ check('the main button reads Schedule', await page.evaluate(() => document.querySelector('.post-actions .btn-primary').textContent.trim() === 'Schedule'));
+ // two hours ahead, typed as the user would
+ const want = new Date(Date.now() + 2 * 3600_000); want.setSeconds(0, 0);
+ const local = new Date(want.getTime() - want.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
+ await page.evaluate((v) => { const i = document.querySelector('.sched-pick input'); i.value = v; i.dispatchEvent(new Event('input', { bubbles: true })); }, local);
+ await page.evaluate(() => { window.__published = []; document.querySelector('.post-actions .btn-primary').click(); });
+ await waitFor(() => !document.querySelector('.post-input'), 8000);
+ const sc = calls.find((c) => c.path === '/schedule');
+ const ev = sc && sc.body.event;
+ check('the notifier gets a signed kind 1 dated at the chosen time', !!ev && ev.kind === 1 && verifyEvent(ev) && ev.created_at === Math.floor(want.getTime() / 1000), ev && new Date(ev.created_at * 1000).toISOString());
+ check('...with its tags (t coinos, client) and the relays to send it to', !!ev && ev.tags.some((x) => x[0] === 't' && x[1] === 'coinos') && ev.tags.some((x) => x[0] === 'client') && sc.body.relays.length > 0, JSON.stringify(sc?.body.relays));
+ check('nothing went to a relay', !(await page.evaluate(() => window.__published.some((e) => e.kind === 1))));
+ check('the composer closed and the draft is cleared', await page.evaluate(() => !document.querySelector('.post-input')));
+ check('the post is not in the feed yet', await page.evaluate(() => !document.body.innerText.includes('later, #coinos') || !!document.querySelector('.sched-list')) && !(await page.evaluate(() => [...document.querySelectorAll('.note-text')].some((n) => n.textContent.includes('later, #coinos')))));
+
+ // the list shows with the composer open and on your profile
+ await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ check('the composer lists it under Scheduled (1)', await waitFor(() => /Scheduled \(1\)/.test(document.querySelector('.sched-list')?.innerText || ''), 5000));
+ check('...with its words', await page.evaluate(() => document.querySelector('.sched-item').innerText.includes('later, #coinos')));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000);
+ await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ check('it survives a reload (and the status check keeps it)', await waitFor(() => !!document.querySelector('.sched-item'), 8000) && calls.some((c) => c.path === '/schedule/status'));
+ await page.evaluate(() => [...document.querySelectorAll('.sched-item button')].find((b) => b.textContent.trim() === 'Cancel').click());
+ await waitFor(() => !document.querySelector('.sched-item'), 8000);
+ const cc = calls.find((c) => c.path === '/schedule/cancel');
+ check('Cancel sends a kind 5 naming it, signed by the author', !!cc && cc.body.event.kind === 5 && verifyEvent(cc.body.event) && cc.body.event.pubkey === ev.pubkey && cc.body.event.tags.some((x) => x[0] === 'e' && x[1] === ev.id));
+ check('...and it leaves the list', await page.evaluate(() => !document.querySelector('.sched-list')));
+ check('no page errors', !errs.length, errs.join(' | '));
+} catch (e) { check('ran', false, e.stack); }
+await browser.close(); server.stop();
+console.log(ok ? '\nALL PASS' : '\nFAILED');
+process.exit(ok ? 0 : 1);