openvibers/openvibe.today Commits
"Remind me" from anywhere: openvibe.today/remind (plan L48 R3) (#44)
Changes
@@@ -269,3 +269,26 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri/* The week's lines read as text (the room's link colour belongs to links in prose), and their times line up. */.td-wrap .td-wi a, .td-wrap .td-wi-t { color: var(--text-primary, #e8eef8); }.td-wi time { min-width: 2.9em; }++++/* ── "Remind me" from anywhere (plan L48 R3): the confirm card and what was set ── */++#td-remind .td-kicker, #td-remind-done .td-kicker { display: inline-flex; align-items: center; gap: 6px; }++#td-remind .td-kicker .ovk-i { width: 1.05em; height: 1.05em; }++.td-remind-when { margin: 10px 0 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }++.td-remind-when time { font-size: clamp(1.15rem, 2.6vw, 1.45rem); font-weight: 800; letter-spacing: -.01em; color: var(--text-primary, #e8eef8); font-variant-numeric: tabular-nums; }++.td-remind-when span { color: var(--text-muted, #6f819d); font-size: .9rem; }++.td-remind-form { margin-top: 18px; display: block; }++.td-remind-leads { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 136px), 1fr)); }++.td-remind-leads legend { grid-column: 1 / -1; }++.td-remind-leads .td-opt { align-items: center; min-height: 48px; padding: 10px 12px; }++.td-remind-leads .td-opt input { margin: 0; }++.td-remind-leads .td-opt:has(input:checked) { border-color: var(--room-accent); box-shadow: inset 0 0 0 1px var(--room-accent); background: color-mix(in srgb, var(--room-accent) 10%, var(--bg-card, #0f1729)); }++.td-remind-form .td-acts .ovk-btn { gap: 8px; }++.td-remind-form .td-fine { margin: 14px 0 0; color: var(--text-muted, #6f819d); font-size: .86rem; line-height: 1.5; }++.td-remind-form .td-fine a { color: var(--room-strong, var(--room-accent)); }++.td-remind-set { margin-top: clamp(16px, 6vh, 56px); padding: clamp(20px, 4vw, 32px); text-align: center; display: grid; justify-items: center; }++.td-remind-set h1 { margin: 2px 0 0; font-size: clamp(1.5rem, 3.6vw, 2.2rem); letter-spacing: -.025em; text-wrap: balance; }++.td-remind-set .td-remind-when { justify-content: center; }++.td-remind-set .td-acts { justify-content: center; margin-top: 18px; }++.td-remind-ok { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 10px; border-radius: 16px; background: color-mix(in srgb, #22c55e 18%, transparent); color: #22c55e; }++.td-remind-ok .ovk-i { width: 26px; height: 26px; }++.td-notice--bad { padding: 12px 14px; border-radius: 12px; border: 1px solid color-mix(in srgb, #ef4444 50%, var(--border, #1f2d47)); background: color-mix(in srgb, #ef4444 10%, transparent); }@@@ -13,9 +13,11 @@try {if (!/(?:^|; )td_tz=/.test(document.cookie) || decodeURIComponent((document.cookie.match(/(?:^|; )td_tz=([^;]*)/) || [])[1] || '') !== zone) {document.cookie = 'td_tz=' + encodeURIComponent(zone) + '; path=/; max-age=31536000; samesite=lax' + (location.protocol === 'https:' ? '; secure' : '');-- var wk = document.getElementById('td-week');-- if (wk && wk.getAttribute('data-tz') !== zone && !/[?&]tz=/.test(location.search) && !sessionStorage.getItem('td-week-tz')) {-- sessionStorage.setItem('td-week-tz', '1');++ // The week, and a "Remind me" that came from another site: both say a time in the reader's zone.++ var wk = document.getElementById('td-week') || document.getElementById('td-remind');++ var once = 'td-tz-' + (wk ? wk.id : '');++ if (wk && wk.getAttribute('data-tz') !== zone && !/[?&]tz=/.test(location.search) && !sessionStorage.getItem(once)) {++ sessionStorage.setItem(once, '1');location.replace(location.pathname + location.search + (location.search ? '&' : '?') + 'tz=' + encodeURIComponent(zone));}}@@@ -234,6 +234,40 @@ function createPageRoutes(ctx) {});});++ // ── "Remind me" from anywhere (plan L48 R3): /remind?title=&at=&url=&from= from any OpenVibe site ──++ const remindArgs = (q) => ({++ title: String(q.title || '').replace(/\s+/g, ' ').trim().slice(0, 200),++ at: String(q.at || '').slice(0, 40),++ url: /^https:\/\//.test(String(q.url || '')) ? String(q.url).slice(0, 2000) : '',++ from: /^[a-z][a-z0-9.-]{1,40}$/.test(String(q.from || '')) ? String(q.from) : '',++ });++ const zoneOf = (req, asked) => {++ const z = String(asked || (req.cookies && req.cookies.td_tz) || '');++ return require('../when').validTz(z) ? z : 'UTC';++ };++ r.get('/remind', async (req, res) => {++ const a = remindArgs(req.query || {});++ if (!signedIn(req)) {++ return page(req, res, { title: 'Remind me', body: raw(views.signIn({ title: a.title ? `Remind me: ${a.title}` : 'Remind me', text: 'Sign in with your OpenVibe account, and the reminder reaches you in the bell and as a push on every OpenVibe site.', next: req.originalUrl })) });++ }++ const tz = zoneOf(req, req.query.tz);++ res.set('Cache-Control', 'private, no-store');++ return page(req, res, { title: a.title ? `Remind me: ${a.title}` : 'Remind me', body: raw(views.remind({ ...a, tz, lead: 10 })) });++ });++ r.post('/remind', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {++ const b = req.body || {};++ const a = remindArgs(b);++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/remind?${new URLSearchParams(a)}`)}`);++ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: raw(views.notFound()) }, 403);++ const tz = zoneOf(req, b.tz);++ if (!mine) return page(req, res, { title: 'Remind me', body: raw(views.remind({ ...a, tz, problem: 'Reminders are not available right now.' })) }, 503);++ const out = await mine.createAt(req.viewer.subject, { title: a.title, at: a.at, lead: b.lead, url: a.url, source: (a.from || hostOf(a.url) || 'today').replace(/^openvibe\./, '').replace(/[^a-z0-9-]/g, '-').slice(0, 30) || 'today', tz });++ if (out.error) return page(req, res, { title: 'Remind me', body: raw(views.remind({ ...a, tz, lead: Number(b.lead) || 10, problem: out.error })) }, 422);++ res.set('Cache-Control', 'private, no-store');++ return page(req, res, { title: 'Reminder set', body: raw(views.remindDone({ reminder: out.reminder, back: a.url, already: out.already, tz })) }, out.already ? 200 : 201);++ });++ const hostOf = (u) => { try { return new URL(u).host; } catch { return ''; } };++// ── A person's own reminders (plan L48, R1): plain forms, so the page works without its script ──const mineGuard = (req, res, next) => {if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fme%23reminders');@@@ -6,6 +6,10 @@* createMyReminders({ s })* preview({ text, tz, now }) → { title, rule, tz, next_at, summary } or { error }* create(subject, { text, tz, url, source }) → the reminder, or { error } (a person keeps at most MAX_ACTIVE)++ * createAt(subject, { title, at, lead, url, source, tz })++ * → a one-off before something with a time on another site ("Remind me" on a++ * stream, a job's closing date, a coupon's end): { reminder, already } or++ * { error }. lead is minutes before (0, 10, 60 or 1440).* list(subject) → active first (soonest first), then the last done ones* done(subject, id) · snooze(subject, id, minutes) · remove(subject, id)* due(now) · claim(row, now) · release(row) the job's side: claim moves next_at on in one UPDATE@@@ -48,6 +52,39 @@ function createMyReminders({ s }) {return wire(row, { now: s.now() });}++ const LEADS = [0, 10, 60, 1440];++ const leadWords = (m) => ({ 0: 'at the time', 10: '10 minutes before', 60: 'an hour before', 1440: 'the day before' })[m] || '';++++ /** A thing with a time on another site → a one-off reminder before it. The same thing set twice is set once. */++ async function createAt(subject, { title, at, lead = 10, url = null, source = 'today', tz = 'UTC', now = s.now() }) {++ const name = String(title == null ? '' : title).replace(/\s+/g, ' ').trim().slice(0, 200);++ if (!name) return { error: 'What should the reminder say?' };++ const when0 = Date.parse(String(at || ''));++ if (!Number.isFinite(when0)) return { error: 'That time could not be read.' };++ if (when0 <= now + 60_000) return { error: 'That time has already passed.' };++ if (when0 > now + 2 * 365 * 86_400_000) return { error: 'That is more than two years away.' };++ const m = LEADS.includes(Number(lead)) ? Number(lead) : 10;++ // Too close for the lead asked: as soon as it can, never in the past.++ const remindAt = new Date(Math.max(now + 60_000, when0 - m * 60_000));++ const zone = when.validTz(tz) ? tz : 'UTC';++ const link = safeUrl(url);++ const src = SOURCES.test(String(source)) ? String(source) : 'today';++ const same = await db.maybe(++ "SELECT * FROM today_my_reminders WHERE subject = $1 AND status = 'active' AND title = $2 AND url IS NOT DISTINCT FROM $3 AND date_trunc('minute', next_at) = date_trunc('minute', $4::timestamptz) LIMIT 1",++ [subject, name, link, remindAt.toISOString()],++ );++ if (same) return { reminder: wire(same, { now }), already: true };++ const active = Number(await db.value("SELECT count(*)::int FROM today_my_reminders WHERE subject = $1 AND status = 'active'", [subject]));++ if (active >= MAX_ACTIVE) return { error: `You have ${MAX_ACTIVE} reminders already. Mark some done first.` };++ const rule = { kind: 'once', at: remindAt.toISOString() };++ const row = await db.maybe(++ `INSERT INTO today_my_reminders (id, subject, title, said, rule, tz, url, source, next_at)++ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9) RETURNING *`,++ [s.newId('rem'), subject, name, `${name} (${leadWords(m)}, from ${src})`.slice(0, 500), JSON.stringify(rule), zone, link, src, remindAt.toISOString()],++ );++ return { reminder: wire(row, { now }), already: false };++ }++async function list(subject) {const rows = await db.many(`(SELECT * FROM today_my_reminders WHERE subject = $1 AND status = 'active' ORDER BY next_at ASC LIMIT ${MAX_ACTIVE})@@@ -104,7 +141,7 @@ function createMyReminders({ s }) {await db.exec("UPDATE today_my_reminders SET next_at = $2, status = 'active', sent_count = GREATEST(sent_count - 1, 0) WHERE id = $1", [row.id, row.next_at]);}-- return { preview, create, list, done, snooze, remove, due, claim, release, wire, MAX_ACTIVE };++ return { preview, create, createAt, LEADS, list, done, snooze, remove, due, claim, release, wire, MAX_ACTIVE };}module.exports = { createMyReminders };@@@ -345,6 +345,50 @@ ${problem ? `<p class="td-note is-bad" role="alert">${esc(problem)}</p>` : ''}</form></div>`;}++/**++ * "Remind me" from anywhere (plan L48 R3): a thing with a time on another OpenVibe site (a stream, a job's closing++ * date, a coupon's end) comes here as /remind?title=&at=&url=&from=, in the person's own zone, with how long before.++ * remind({ title, at, url, from, tz, lead, problem }) the confirm card++ * remindDone({ reminder, back, already, tz }) what was set, and the way back++ */++const LEAD_CHOICES = [[0, 'At the time', null], [10, '10 minutes before', null], [60, 'An hour before', null], [1440, 'The day before', null]];++const hostOf = (u) => { try { return new URL(u).host.replace(/^www\./, ''); } catch { return ''; } };++const whenIn = (iso, tz) => {++ try {++ const d = new Date(iso);++ const day = new Intl.DateTimeFormat('en-US', { timeZone: tz, weekday: 'short', month: 'short', day: 'numeric' }).format(d);++ const time = new Intl.DateTimeFormat('en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit' }).format(d);++ return `${day} at ${time}`;++ } catch { return new Date(iso).toISOString().slice(0, 16).replace('T', ' '); }++};++++function remind({ title = '', at = '', url = '', from = '', tz = 'UTC', lead = 10, problem = '' } = {}) {++ const host = hostOf(url) || (from ? from : '');++ const valid = title && Number.isFinite(Date.parse(at));++ const hidden = (n, v) => `<input type="hidden" name="${n}" value="${esc(v)}">`;++ const radio = ([m, label]) => `<label class="td-opt"><input type="radio" name="lead" value="${m}"${Number(lead) === m ? ' checked' : ''}><span><b>${esc(label)}</b></span></label>`;++ return `<div class="td-wrap td-page-pad td-narrow" id="td-remind" data-tz="${esc(tz)}">++<header class="td-page-h"><p class="td-kicker">${kit.icon('bell')} Remind me${host ? ` · from ${esc(host)}` : ''}</p>++<h1>${valid ? esc(title) : 'Nothing to remind you of'}</h1>++${valid ? `<p class="td-remind-when"><time datetime="${esc(new Date(at).toISOString())}">${esc(whenIn(at, tz))}</time><span>your time, ${esc(tz.replace(/_/g, ' '))}</span></p>` : '<p class="td-lede">This link has no title or no time. Open it again from the page it came from.</p>'}</header>++${problem ? `<p class="td-notice td-notice--bad" role="alert">${esc(problem)}</p>` : ''}++${valid ? `<form class="td-card td-form td-remind-form" method="post" action="/remind">${hidden('title', title)}${hidden('at', at)}${hidden('url', url)}${hidden('from', from)}<input type="hidden" name="tz" value="${esc(tz)}" data-tz>++<fieldset class="td-opts td-remind-leads"><legend>When should it reach you?</legend>${LEAD_CHOICES.map(radio).join('')}</fieldset>++<div class="td-acts"><button class="ovk-btn ovk-btn--primary" type="submit">${kit.icon('bell')} Remind me</button>${url ? `<a class="ovk-btn ovk-btn--quiet" href="${esc(url)}">Back to ${esc(host || 'the page')}</a>` : ''}</div>++<p class="td-fine">It reaches you in the bell and as a push on every OpenVibe site. Snooze or stop it on <a href="/me#reminders">your agenda</a>.</p></form>` : ''}++</div>`;++}++++function remindDone({ reminder, back = '', already = false, tz = 'UTC' } = {}) {++ const host = hostOf(back);++ return `<div class="td-wrap td-page-pad td-narrow" id="td-remind-done">++<div class="td-card td-remind-set"><span class="td-remind-ok" aria-hidden="true">${kit.icon('check')}</span>++<p class="td-kicker">${already ? 'Already set' : 'Reminder set'}</p><h1>${esc(reminder.title)}</h1>++<p class="td-remind-when"><time datetime="${esc(reminder.next_at || '')}">${esc(reminder.next_at ? whenIn(reminder.next_at, tz) : '')}</time><span>it reaches you then, in the bell and as a push</span></p>++<div class="td-acts">${back ? `<a class="ovk-btn ovk-btn--primary" href="${esc(back)}">Back to ${esc(host || 'where you were')}</a>` : ''}<a class="ovk-btn ovk-btn--quiet" href="/me#reminders">Your reminders</a><a class="ovk-btn ovk-btn--quiet" href="/me/week">Your week</a></div></div>++</div>`;++}++function signIn({ title, text, next }) {return `<div class="td-wrap td-page-pad td-narrow"><div class="td-empty td-signin">${kit.icon('calendar')}<h1>${esc(title)}</h1><p>${esc(text)}</p>${kit.button({ href: `/auth/login?next=${encodeURIComponent(next)}`, label: 'Sign in with OpenVibe', kind: 'primary' })}</div></div>`;}@@@ -379,4 +423,4 @@ function notFound() {return `<div class="td-wrap td-page-pad td-narrow"><div class="td-empty">${kit.icon('calendar')}<h1>Not on the calendar</h1><p>There is nothing at this address. It may have been cancelled, or it is for followers only.</p>${kit.button({ href: '/', label: 'See what is coming up', kind: 'quiet' })}</div></div>`;}--module.exports = { week, home, channel, event, me, brief, schedule, signIn, developers, notFound, ZONES, LENGTHS, parts, dayKey };++module.exports = { remind, remindDone, week, home, channel, event, me, brief, schedule, signIn, developers, notFound, ZONES, LENGTHS, parts, dayKey };nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++/**++ * "Remind me" from anywhere (plan L48 R3): /remind?title=&at=&url=&from= from any OpenVibe site. Signed out, sign in++ * and come back; signed in, the time in the person's own zone and how long before; a one-off reminder before it, the++ * same thing set twice set once, a past time refused, a lead too long for the time left made as soon as it can be, a++ * link that is not https dropped, and only openvibe.today itself may post it.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const ana = t.network.addUser('ana');++ const hour = 3_600_000;++ const at = new Date(Math.ceil((Date.now() + 3 * 24 * hour) / hour) * hour).toISOString();++ const q = new URLSearchParams({ title: 'Surf race, heat 3', at, url: 'https://openvibe.live/@surfwax', from: 'openvibe.live' });++ const paris = (iso) => {++ const d = new Date(iso);++ return `${new Intl.DateTimeFormat('en-US', { timeZone: 'Europe/Paris', weekday: 'short', month: 'short', day: 'numeric' }).format(d)} at ${new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }).format(d)}`;++ };++ const rows = () => t.ctx.s.db.many("SELECT * FROM today_my_reminders WHERE source <> 'today' ORDER BY created_at");++ try {++ await check('signed out: sign in, and come back to the same reminder', async () => {++ const r = await t.get(`/remind?${q}`);++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Remind me: Surf race, heat 3/);++ assert.ok(r.text.includes(encodeURIComponent(`/remind?${q}`)), 'the sign-in comes back here');++ const post = await t.get('/remind', { form: Object.fromEntries(q), origin: t.config.baseUrl });++ assert.strictEqual(post.status, 303);++ assert.match(post.headers.get('location'), /^\/auth\/login\?next=%2Fremind%3F/);++ });++++ await check('signed in: the time in the person\'s zone, and how long before', async () => {++ const r = await t.get(`/remind?${q}`, { as: ana, headers: { cookie: 'td_tz=Europe%2FParis' } });++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /<div class="td-wrap td-page-pad td-narrow" id="td-remind" data-tz="Europe\/Paris">/);++ assert.ok(r.text.includes(paris(at)), `the time in Paris: ${paris(at)}`);++ assert.match(r.text, /from openvibe\.live/);++ assert.match(r.text, /<input type="radio" name="lead" value="10" checked>/, '10 minutes before, unless they choose');++ assert.match(r.text, /href="https:\/\/openvibe\.live\/@surfwax">Back to openvibe\.live/);++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ });++++ await check('set: a one-off before it, the way back, and set once', async () => {++ const form = { ...Object.fromEntries(q), lead: '60', tz: 'Europe/Paris' };++ const r = await t.get('/remind', { as: ana, form });++ assert.strictEqual(r.status, 201, r.text.slice(0, 300));++ assert.match(r.text, /Reminder set/);++ assert.match(r.text, /Back to openvibe\.live/);++ const [row] = await rows();++ assert.strictEqual(new Date(row.next_at).toISOString(), new Date(Date.parse(at) - hour).toISOString(), 'an hour before');++ assert.deepStrictEqual([row.title, row.url, row.source, row.tz, row.status], ['Surf race, heat 3', 'https://openvibe.live/@surfwax', 'live', 'Europe/Paris', 'active']);++ assert.strictEqual((typeof row.rule === 'string' ? JSON.parse(row.rule) : row.rule).kind, 'once');++ const again = await t.get('/remind', { as: ana, form });++ assert.strictEqual(again.status, 200);++ assert.match(again.text, /Already set/);++ assert.strictEqual((await rows()).length, 1, 'set once');++ assert.ok((await t.get('/me', { as: ana })).text.includes('Surf race, heat 3'), 'on the agenda');++ });++++ await check('a past time, a lead longer than the time left, a link that is not https, someone else\'s site', async () => {++ const past = await t.get('/remind', { as: ana, form: { title: 'Gone', at: new Date(Date.now() - hour).toISOString(), lead: '10' } });++ assert.strictEqual(past.status, 422);++ assert.match(past.text, /That time has already passed/);++ const soon = await t.get('/remind', { as: ana, form: { title: 'Soon', at: new Date(Date.now() + 2 * hour).toISOString(), lead: '1440' } });++ assert.strictEqual(soon.status, 201);++ const s = await t.ctx.s.db.maybe("SELECT * FROM today_my_reminders WHERE title = 'Soon'");++ assert.ok(Date.parse(s.next_at) - Date.now() < 5 * 60_000, 'the day before has passed: as soon as it can, never in the past');++ const odd = await t.get(`/remind?${new URLSearchParams({ title: '<b>Odd</b>', at, url: 'javascript:alert(1)' })}`, { as: ana });++ assert.ok(odd.text.includes('<b>Odd</b>') && !odd.text.includes('<b>Odd</b>'), 'the title is text');++ assert.ok(!odd.text.includes('javascript:'), 'a link that is not https is dropped');++ const cross = await t.get('/remind', { as: ana, form: { ...Object.fromEntries(q), lead: '10' }, origin: 'https://evil.example' });++ assert.strictEqual(cross.status, 403, 'only openvibe.today itself may set it');++ const blank = await t.get('/remind?title=&at=', { as: ana });++ assert.match(blank.text, /Nothing to remind you of/);++ });++ } finally {++ await done(t);++ }++})();
@@ -269,3 +269,26 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri
@@ -269,3 +269,26 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri
/* The week's lines read as text (the room's link colour belongs to links in prose), and their times line up. */
/* The week's lines read as text (the room's link colour belongs to links in prose), and their times line up. */
.td-wrap .td-wi a, .td-wrap .td-wi-t { color: var(--text-primary, #e8eef8); }
.td-wrap .td-wi a, .td-wrap .td-wi-t { color: var(--text-primary, #e8eef8); }
.td-wi time { min-width: 2.9em; }
.td-wi time { min-width: 2.9em; }
+
+/* ── "Remind me" from anywhere (plan L48 R3): the confirm card and what was set ── */
+#td-remind .td-kicker, #td-remind-done .td-kicker { display: inline-flex; align-items: center; gap: 6px; }
+#td-remind .td-kicker .ovk-i { width: 1.05em; height: 1.05em; }
+.td-remind-when { margin: 10px 0 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }
+.td-remind-when time { font-size: clamp(1.15rem, 2.6vw, 1.45rem); font-weight: 800; letter-spacing: -.01em; color: var(--text-primary, #e8eef8); font-variant-numeric: tabular-nums; }
+.td-remind-when span { color: var(--text-muted, #6f819d); font-size: .9rem; }
+.td-remind-form { margin-top: 18px; display: block; }
+.td-remind-leads { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 136px), 1fr)); }
+.td-remind-leads legend { grid-column: 1 / -1; }
+.td-remind-leads .td-opt { align-items: center; min-height: 48px; padding: 10px 12px; }
+.td-remind-leads .td-opt input { margin: 0; }
+.td-remind-leads .td-opt:has(input:checked) { border-color: var(--room-accent); box-shadow: inset 0 0 0 1px var(--room-accent); background: color-mix(in srgb, var(--room-accent) 10%, var(--bg-card, #0f1729)); }
+.td-remind-form .td-acts .ovk-btn { gap: 8px; }
+.td-remind-form .td-fine { margin: 14px 0 0; color: var(--text-muted, #6f819d); font-size: .86rem; line-height: 1.5; }
+.td-remind-form .td-fine a { color: var(--room-strong, var(--room-accent)); }
+.td-remind-set { margin-top: clamp(16px, 6vh, 56px); padding: clamp(20px, 4vw, 32px); text-align: center; display: grid; justify-items: center; }
+.td-remind-set h1 { margin: 2px 0 0; font-size: clamp(1.5rem, 3.6vw, 2.2rem); letter-spacing: -.025em; text-wrap: balance; }
+.td-remind-set .td-remind-when { justify-content: center; }
+.td-remind-set .td-acts { justify-content: center; margin-top: 18px; }
+.td-remind-ok { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 10px; border-radius: 16px; background: color-mix(in srgb, #22c55e 18%, transparent); color: #22c55e; }
+.td-remind-ok .ovk-i { width: 26px; height: 26px; }
+.td-notice--bad { padding: 12px 14px; border-radius: 12px; border: 1px solid color-mix(in srgb, #ef4444 50%, var(--border, #1f2d47)); background: color-mix(in srgb, #ef4444 10%, transparent); }
@@ -13,9 +13,11 @@
@@ -13,9 +13,11 @@
try {
try {
if (!/(?:^|; )td_tz=/.test(document.cookie) || decodeURIComponent((document.cookie.match(/(?:^|; )td_tz=([^;]*)/) || [])[1] || '') !== zone) {
if (!/(?:^|; )td_tz=/.test(document.cookie) || decodeURIComponent((document.cookie.match(/(?:^|; )td_tz=([^;]*)/) || [])[1] || '') !== zone) {
document.cookie = 'td_tz=' + encodeURIComponent(zone) + '; path=/; max-age=31536000; samesite=lax' + (location.protocol === 'https:' ? '; secure' : '');
document.cookie = 'td_tz=' + encodeURIComponent(zone) + '; path=/; max-age=31536000; samesite=lax' + (location.protocol === 'https:' ? '; secure' : '');
- var wk = document.getElementById('td-week');
- if (wk && wk.getAttribute('data-tz') !== zone && !/[?&]tz=/.test(location.search) && !sessionStorage.getItem('td-week-tz')) {
- sessionStorage.setItem('td-week-tz', '1');
+ // The week, and a "Remind me" that came from another site: both say a time in the reader's zone.
+ var wk = document.getElementById('td-week') || document.getElementById('td-remind');
+ var once = 'td-tz-' + (wk ? wk.id : '');
+ if (wk && wk.getAttribute('data-tz') !== zone && !/[?&]tz=/.test(location.search) && !sessionStorage.getItem(once)) {
+ sessionStorage.setItem(once, '1');
location.replace(location.pathname + location.search + (location.search ? '&' : '?') + 'tz=' + encodeURIComponent(zone));
location.replace(location.pathname + location.search + (location.search ? '&' : '?') + 'tz=' + encodeURIComponent(zone));
}
}
}
}
@@ -234,6 +234,40 @@ function createPageRoutes(ctx) {
@@ -234,6 +234,40 @@ function createPageRoutes(ctx) {
});
});
});
});
+ // ── "Remind me" from anywhere (plan L48 R3): /remind?title=&at=&url=&from= from any OpenVibe site ──
+ const remindArgs = (q) => ({
+ title: String(q.title || '').replace(/\s+/g, ' ').trim().slice(0, 200),
+ at: String(q.at || '').slice(0, 40),
+ url: /^https:\/\//.test(String(q.url || '')) ? String(q.url).slice(0, 2000) : '',
+ from: /^[a-z][a-z0-9.-]{1,40}$/.test(String(q.from || '')) ? String(q.from) : '',
+ });
+ const zoneOf = (req, asked) => {
+ const z = String(asked || (req.cookies && req.cookies.td_tz) || '');
+ return require('../when').validTz(z) ? z : 'UTC';
+ };
+ r.get('/remind', async (req, res) => {
+ const a = remindArgs(req.query || {});
+ if (!signedIn(req)) {
+ return page(req, res, { title: 'Remind me', body: raw(views.signIn({ title: a.title ? `Remind me: ${a.title}` : 'Remind me', text: 'Sign in with your OpenVibe account, and the reminder reaches you in the bell and as a push on every OpenVibe site.', next: req.originalUrl })) });
+ }
+ const tz = zoneOf(req, req.query.tz);
+ res.set('Cache-Control', 'private, no-store');
+ return page(req, res, { title: a.title ? `Remind me: ${a.title}` : 'Remind me', body: raw(views.remind({ ...a, tz, lead: 10 })) });
+ });
+ r.post('/remind', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
+ const b = req.body || {};
+ const a = remindArgs(b);
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/remind?${new URLSearchParams(a)}`)}`);
+ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: raw(views.notFound()) }, 403);
+ const tz = zoneOf(req, b.tz);
+ if (!mine) return page(req, res, { title: 'Remind me', body: raw(views.remind({ ...a, tz, problem: 'Reminders are not available right now.' })) }, 503);
+ const out = await mine.createAt(req.viewer.subject, { title: a.title, at: a.at, lead: b.lead, url: a.url, source: (a.from || hostOf(a.url) || 'today').replace(/^openvibe\./, '').replace(/[^a-z0-9-]/g, '-').slice(0, 30) || 'today', tz });
+ if (out.error) return page(req, res, { title: 'Remind me', body: raw(views.remind({ ...a, tz, lead: Number(b.lead) || 10, problem: out.error })) }, 422);
+ res.set('Cache-Control', 'private, no-store');
+ return page(req, res, { title: 'Reminder set', body: raw(views.remindDone({ reminder: out.reminder, back: a.url, already: out.already, tz })) }, out.already ? 200 : 201);
+ });
+ const hostOf = (u) => { try { return new URL(u).host; } catch { return ''; } };
+
// ── A person's own reminders (plan L48, R1): plain forms, so the page works without its script ──
// ── A person's own reminders (plan L48, R1): plain forms, so the page works without its script ──
const mineGuard = (req, res, next) => {
const mineGuard = (req, res, next) => {
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fme%23reminders');
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fme%23reminders');
@@ -6,6 +6,10 @@
@@ -6,6 +6,10 @@
* createMyReminders({ s })
* createMyReminders({ s })
* preview({ text, tz, now }) → { title, rule, tz, next_at, summary } or { error }
* preview({ text, tz, now }) → { title, rule, tz, next_at, summary } or { error }
* create(subject, { text, tz, url, source }) → the reminder, or { error } (a person keeps at most MAX_ACTIVE)
* create(subject, { text, tz, url, source }) → the reminder, or { error } (a person keeps at most MAX_ACTIVE)
+ * createAt(subject, { title, at, lead, url, source, tz })
+ * → a one-off before something with a time on another site ("Remind me" on a
+ * stream, a job's closing date, a coupon's end): { reminder, already } or
+ * { error }. lead is minutes before (0, 10, 60 or 1440).
* list(subject) → active first (soonest first), then the last done ones
* list(subject) → active first (soonest first), then the last done ones
* done(subject, id) · snooze(subject, id, minutes) · remove(subject, id)
* done(subject, id) · snooze(subject, id, minutes) · remove(subject, id)
* due(now) · claim(row, now) · release(row) the job's side: claim moves next_at on in one UPDATE
* due(now) · claim(row, now) · release(row) the job's side: claim moves next_at on in one UPDATE
@@ -48,6 +52,39 @@ function createMyReminders({ s }) {
@@ -48,6 +52,39 @@ function createMyReminders({ s }) {
return wire(row, { now: s.now() });
return wire(row, { now: s.now() });
}
}
+ const LEADS = [0, 10, 60, 1440];
+ const leadWords = (m) => ({ 0: 'at the time', 10: '10 minutes before', 60: 'an hour before', 1440: 'the day before' })[m] || '';
+
+ /** A thing with a time on another site → a one-off reminder before it. The same thing set twice is set once. */
+ async function createAt(subject, { title, at, lead = 10, url = null, source = 'today', tz = 'UTC', now = s.now() }) {
+ const name = String(title == null ? '' : title).replace(/\s+/g, ' ').trim().slice(0, 200);
+ if (!name) return { error: 'What should the reminder say?' };
+ const when0 = Date.parse(String(at || ''));
+ if (!Number.isFinite(when0)) return { error: 'That time could not be read.' };
+ if (when0 <= now + 60_000) return { error: 'That time has already passed.' };
+ if (when0 > now + 2 * 365 * 86_400_000) return { error: 'That is more than two years away.' };
+ const m = LEADS.includes(Number(lead)) ? Number(lead) : 10;
+ // Too close for the lead asked: as soon as it can, never in the past.
+ const remindAt = new Date(Math.max(now + 60_000, when0 - m * 60_000));
+ const zone = when.validTz(tz) ? tz : 'UTC';
+ const link = safeUrl(url);
+ const src = SOURCES.test(String(source)) ? String(source) : 'today';
+ const same = await db.maybe(
+ "SELECT * FROM today_my_reminders WHERE subject = $1 AND status = 'active' AND title = $2 AND url IS NOT DISTINCT FROM $3 AND date_trunc('minute', next_at) = date_trunc('minute', $4::timestamptz) LIMIT 1",
+ [subject, name, link, remindAt.toISOString()],
+ );
+ if (same) return { reminder: wire(same, { now }), already: true };
+ const active = Number(await db.value("SELECT count(*)::int FROM today_my_reminders WHERE subject = $1 AND status = 'active'", [subject]));
+ if (active >= MAX_ACTIVE) return { error: `You have ${MAX_ACTIVE} reminders already. Mark some done first.` };
+ const rule = { kind: 'once', at: remindAt.toISOString() };
+ const row = await db.maybe(
+ `INSERT INTO today_my_reminders (id, subject, title, said, rule, tz, url, source, next_at)
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9) RETURNING *`,
+ [s.newId('rem'), subject, name, `${name} (${leadWords(m)}, from ${src})`.slice(0, 500), JSON.stringify(rule), zone, link, src, remindAt.toISOString()],
+ );
+ return { reminder: wire(row, { now }), already: false };
+ }
+
async function list(subject) {
async function list(subject) {
const rows = await db.many(
const rows = await db.many(
`(SELECT * FROM today_my_reminders WHERE subject = $1 AND status = 'active' ORDER BY next_at ASC LIMIT ${MAX_ACTIVE})
`(SELECT * FROM today_my_reminders WHERE subject = $1 AND status = 'active' ORDER BY next_at ASC LIMIT ${MAX_ACTIVE})
@@ -104,7 +141,7 @@ function createMyReminders({ s }) {
@@ -104,7 +141,7 @@ function createMyReminders({ s }) {
await db.exec("UPDATE today_my_reminders SET next_at = $2, status = 'active', sent_count = GREATEST(sent_count - 1, 0) WHERE id = $1", [row.id, row.next_at]);
await db.exec("UPDATE today_my_reminders SET next_at = $2, status = 'active', sent_count = GREATEST(sent_count - 1, 0) WHERE id = $1", [row.id, row.next_at]);
}
}
- return { preview, create, list, done, snooze, remove, due, claim, release, wire, MAX_ACTIVE };
+ return { preview, create, createAt, LEADS, list, done, snooze, remove, due, claim, release, wire, MAX_ACTIVE };
}
}
module.exports = { createMyReminders };
module.exports = { createMyReminders };
@@ -345,6 +345,50 @@ ${problem ? `<p class="td-note is-bad" role="alert">${esc(problem)}</p>` : ''}
@@ -345,6 +345,50 @@ ${problem ? `<p class="td-note is-bad" role="alert">${esc(problem)}</p>` : ''}
</form></div>`;
</form></div>`;
}
}
+/**
+ * "Remind me" from anywhere (plan L48 R3): a thing with a time on another OpenVibe site (a stream, a job's closing
+ * date, a coupon's end) comes here as /remind?title=&at=&url=&from=, in the person's own zone, with how long before.
+ * remind({ title, at, url, from, tz, lead, problem }) the confirm card
+ * remindDone({ reminder, back, already, tz }) what was set, and the way back
+ */
+const LEAD_CHOICES = [[0, 'At the time', null], [10, '10 minutes before', null], [60, 'An hour before', null], [1440, 'The day before', null]];
+const hostOf = (u) => { try { return new URL(u).host.replace(/^www\./, ''); } catch { return ''; } };
+const whenIn = (iso, tz) => {
+ try {
+ const d = new Date(iso);
+ const day = new Intl.DateTimeFormat('en-US', { timeZone: tz, weekday: 'short', month: 'short', day: 'numeric' }).format(d);
+ const time = new Intl.DateTimeFormat('en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit' }).format(d);
+ return `${day} at ${time}`;
+ } catch { return new Date(iso).toISOString().slice(0, 16).replace('T', ' '); }
+};
+
+function remind({ title = '', at = '', url = '', from = '', tz = 'UTC', lead = 10, problem = '' } = {}) {
+ const host = hostOf(url) || (from ? from : '');
+ const valid = title && Number.isFinite(Date.parse(at));
+ const hidden = (n, v) => `<input type="hidden" name="${n}" value="${esc(v)}">`;
+ const radio = ([m, label]) => `<label class="td-opt"><input type="radio" name="lead" value="${m}"${Number(lead) === m ? ' checked' : ''}><span><b>${esc(label)}</b></span></label>`;
+ return `<div class="td-wrap td-page-pad td-narrow" id="td-remind" data-tz="${esc(tz)}">
+<header class="td-page-h"><p class="td-kicker">${kit.icon('bell')} Remind me${host ? ` · from ${esc(host)}` : ''}</p>
+<h1>${valid ? esc(title) : 'Nothing to remind you of'}</h1>
+${valid ? `<p class="td-remind-when"><time datetime="${esc(new Date(at).toISOString())}">${esc(whenIn(at, tz))}</time><span>your time, ${esc(tz.replace(/_/g, ' '))}</span></p>` : '<p class="td-lede">This link has no title or no time. Open it again from the page it came from.</p>'}</header>
+${problem ? `<p class="td-notice td-notice--bad" role="alert">${esc(problem)}</p>` : ''}
+${valid ? `<form class="td-card td-form td-remind-form" method="post" action="/remind">${hidden('title', title)}${hidden('at', at)}${hidden('url', url)}${hidden('from', from)}<input type="hidden" name="tz" value="${esc(tz)}" data-tz>
+<fieldset class="td-opts td-remind-leads"><legend>When should it reach you?</legend>${LEAD_CHOICES.map(radio).join('')}</fieldset>
+<div class="td-acts"><button class="ovk-btn ovk-btn--primary" type="submit">${kit.icon('bell')} Remind me</button>${url ? `<a class="ovk-btn ovk-btn--quiet" href="${esc(url)}">Back to ${esc(host || 'the page')}</a>` : ''}</div>
+<p class="td-fine">It reaches you in the bell and as a push on every OpenVibe site. Snooze or stop it on <a href="/me#reminders">your agenda</a>.</p></form>` : ''}
+</div>`;
+}
+
+function remindDone({ reminder, back = '', already = false, tz = 'UTC' } = {}) {
+ const host = hostOf(back);
+ return `<div class="td-wrap td-page-pad td-narrow" id="td-remind-done">
+<div class="td-card td-remind-set"><span class="td-remind-ok" aria-hidden="true">${kit.icon('check')}</span>
+<p class="td-kicker">${already ? 'Already set' : 'Reminder set'}</p><h1>${esc(reminder.title)}</h1>
+<p class="td-remind-when"><time datetime="${esc(reminder.next_at || '')}">${esc(reminder.next_at ? whenIn(reminder.next_at, tz) : '')}</time><span>it reaches you then, in the bell and as a push</span></p>
+<div class="td-acts">${back ? `<a class="ovk-btn ovk-btn--primary" href="${esc(back)}">Back to ${esc(host || 'where you were')}</a>` : ''}<a class="ovk-btn ovk-btn--quiet" href="/me#reminders">Your reminders</a><a class="ovk-btn ovk-btn--quiet" href="/me/week">Your week</a></div></div>
+</div>`;
+}
+
function signIn({ title, text, next }) {
function signIn({ title, text, next }) {
return `<div class="td-wrap td-page-pad td-narrow"><div class="td-empty td-signin">${kit.icon('calendar')}<h1>${esc(title)}</h1><p>${esc(text)}</p>${kit.button({ href: `/auth/login?next=${encodeURIComponent(next)}`, label: 'Sign in with OpenVibe', kind: 'primary' })}</div></div>`;
return `<div class="td-wrap td-page-pad td-narrow"><div class="td-empty td-signin">${kit.icon('calendar')}<h1>${esc(title)}</h1><p>${esc(text)}</p>${kit.button({ href: `/auth/login?next=${encodeURIComponent(next)}`, label: 'Sign in with OpenVibe', kind: 'primary' })}</div></div>`;
}
}
@@ -379,4 +423,4 @@ function notFound() {
@@ -379,4 +423,4 @@ function notFound() {
return `<div class="td-wrap td-page-pad td-narrow"><div class="td-empty">${kit.icon('calendar')}<h1>Not on the calendar</h1><p>There is nothing at this address. It may have been cancelled, or it is for followers only.</p>${kit.button({ href: '/', label: 'See what is coming up', kind: 'quiet' })}</div></div>`;
return `<div class="td-wrap td-page-pad td-narrow"><div class="td-empty">${kit.icon('calendar')}<h1>Not on the calendar</h1><p>There is nothing at this address. It may have been cancelled, or it is for followers only.</p>${kit.button({ href: '/', label: 'See what is coming up', kind: 'quiet' })}</div></div>`;
}
}
-module.exports = { week, home, channel, event, me, brief, schedule, signIn, developers, notFound, ZONES, LENGTHS, parts, dayKey };
+module.exports = { remind, remindDone, week, home, channel, event, me, brief, schedule, signIn, developers, notFound, ZONES, LENGTHS, parts, dayKey };
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+/**
+ * "Remind me" from anywhere (plan L48 R3): /remind?title=&at=&url=&from= from any OpenVibe site. Signed out, sign in
+ * and come back; signed in, the time in the person's own zone and how long before; a one-off reminder before it, the
+ * same thing set twice set once, a past time refused, a lead too long for the time left made as soon as it can be, a
+ * link that is not https dropped, and only openvibe.today itself may post it.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const ana = t.network.addUser('ana');
+ const hour = 3_600_000;
+ const at = new Date(Math.ceil((Date.now() + 3 * 24 * hour) / hour) * hour).toISOString();
+ const q = new URLSearchParams({ title: 'Surf race, heat 3', at, url: 'https://openvibe.live/@surfwax', from: 'openvibe.live' });
+ const paris = (iso) => {
+ const d = new Date(iso);
+ return `${new Intl.DateTimeFormat('en-US', { timeZone: 'Europe/Paris', weekday: 'short', month: 'short', day: 'numeric' }).format(d)} at ${new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }).format(d)}`;
+ };
+ const rows = () => t.ctx.s.db.many("SELECT * FROM today_my_reminders WHERE source <> 'today' ORDER BY created_at");
+ try {
+ await check('signed out: sign in, and come back to the same reminder', async () => {
+ const r = await t.get(`/remind?${q}`);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Remind me: Surf race, heat 3/);
+ assert.ok(r.text.includes(encodeURIComponent(`/remind?${q}`)), 'the sign-in comes back here');
+ const post = await t.get('/remind', { form: Object.fromEntries(q), origin: t.config.baseUrl });
+ assert.strictEqual(post.status, 303);
+ assert.match(post.headers.get('location'), /^\/auth\/login\?next=%2Fremind%3F/);
+ });
+
+ await check('signed in: the time in the person\'s zone, and how long before', async () => {
+ const r = await t.get(`/remind?${q}`, { as: ana, headers: { cookie: 'td_tz=Europe%2FParis' } });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /<div class="td-wrap td-page-pad td-narrow" id="td-remind" data-tz="Europe\/Paris">/);
+ assert.ok(r.text.includes(paris(at)), `the time in Paris: ${paris(at)}`);
+ assert.match(r.text, /from openvibe\.live/);
+ assert.match(r.text, /<input type="radio" name="lead" value="10" checked>/, '10 minutes before, unless they choose');
+ assert.match(r.text, /href="https:\/\/openvibe\.live\/@surfwax">Back to openvibe\.live/);
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ });
+
+ await check('set: a one-off before it, the way back, and set once', async () => {
+ const form = { ...Object.fromEntries(q), lead: '60', tz: 'Europe/Paris' };
+ const r = await t.get('/remind', { as: ana, form });
+ assert.strictEqual(r.status, 201, r.text.slice(0, 300));
+ assert.match(r.text, /Reminder set/);
+ assert.match(r.text, /Back to openvibe\.live/);
+ const [row] = await rows();
+ assert.strictEqual(new Date(row.next_at).toISOString(), new Date(Date.parse(at) - hour).toISOString(), 'an hour before');
+ assert.deepStrictEqual([row.title, row.url, row.source, row.tz, row.status], ['Surf race, heat 3', 'https://openvibe.live/@surfwax', 'live', 'Europe/Paris', 'active']);
+ assert.strictEqual((typeof row.rule === 'string' ? JSON.parse(row.rule) : row.rule).kind, 'once');
+ const again = await t.get('/remind', { as: ana, form });
+ assert.strictEqual(again.status, 200);
+ assert.match(again.text, /Already set/);
+ assert.strictEqual((await rows()).length, 1, 'set once');
+ assert.ok((await t.get('/me', { as: ana })).text.includes('Surf race, heat 3'), 'on the agenda');
+ });
+
+ await check('a past time, a lead longer than the time left, a link that is not https, someone else\'s site', async () => {
+ const past = await t.get('/remind', { as: ana, form: { title: 'Gone', at: new Date(Date.now() - hour).toISOString(), lead: '10' } });
+ assert.strictEqual(past.status, 422);
+ assert.match(past.text, /That time has already passed/);
+ const soon = await t.get('/remind', { as: ana, form: { title: 'Soon', at: new Date(Date.now() + 2 * hour).toISOString(), lead: '1440' } });
+ assert.strictEqual(soon.status, 201);
+ const s = await t.ctx.s.db.maybe("SELECT * FROM today_my_reminders WHERE title = 'Soon'");
+ assert.ok(Date.parse(s.next_at) - Date.now() < 5 * 60_000, 'the day before has passed: as soon as it can, never in the past');
+ const odd = await t.get(`/remind?${new URLSearchParams({ title: '<b>Odd</b>', at, url: 'javascript:alert(1)' })}`, { as: ana });
+ assert.ok(odd.text.includes('<b>Odd</b>') && !odd.text.includes('<b>Odd</b>'), 'the title is text');
+ assert.ok(!odd.text.includes('javascript:'), 'a link that is not https is dropped');
+ const cross = await t.get('/remind', { as: ana, form: { ...Object.fromEntries(q), lead: '10' }, origin: 'https://evil.example' });
+ assert.strictEqual(cross.status, 403, 'only openvibe.today itself may set it');
+ const blank = await t.get('/remind?title=&at=', { as: ana });
+ assert.match(blank.text, /Nothing to remind you of/);
+ });
+ } finally {
+ await done(t);
+ }
+})();