| … | … | @@ -746,6 +746,11 @@ export function post (row, profiles, files = null, from = '/') { |
| 746 | 746 | // Every post is a way into its own conversation. The thread page is where replies live, because a |
| 747 | 747 | // timeline that inlined them would stop being a timeline. |
| 748 | 748 | const threadBtn = '<a class="go" href="/t/' + encodeURIComponent(target) + '">thread</a>' |
| 749 | // ⛔ MY posts and replies only (the daemon marks `own`), and a LINK to a page that asks first: the | |
| 750 | // delete reaches bsky and the fediverse, so it is never one press beside like. | |
| 751 | const deleteBtn = row.own === true && (row.collection === 'post' || row.collection === 'reply') | |
| 752 | ? '<a class="go" href="/delete?target=' + encodeURIComponent(target) + '&from=' + encodeURIComponent(from) + '">delete</a>' | |
| 753 | : '' | |
| 749 | 754 | |
| 750 | 755 | /** |
| 751 | 756 | * ⛔ TWO COLUMNS — avatar in a gutter, everything else beside it. Every mature client in this |
| … | … | @@ -759,7 +764,7 @@ export function post (row, profiles, files = null, from = '/') { |
| 759 | 764 | '<div class="post-col">' + |
| 760 | 765 | '<div class="meta">' + byline + '<span class="sep">·</span><time>' + when + '</time></div>' + |
| 761 | 766 | render(row.body || '', blobResolver(files)) + |
| 762 | '<div class="reactions">' + likeBtn + repostBtn + quoteBtn + threadBtn + emoji + | |
| 767 | '<div class="reactions">' + likeBtn + repostBtn + quoteBtn + threadBtn + deleteBtn + emoji + | |
| 763 | 768 | '</div></div></article>' |
| 764 | 769 | } |
| 765 | 770 | |
| … | … | @@ -2087,6 +2092,28 @@ export function bridgeRelease (name, message = null) { |
| 2087 | 2092 | '<p><a href="/settings">keep it</a></p>') |
| 2088 | 2093 | } |
| 2089 | 2094 | |
| 2095 | /** | |
| 2096 | * Delete one of my posts or replies — the page that asks first. | |
| 2097 | * | |
| 2098 | * ⛔ IT SAYS WHERE ELSE IT GOES. The delete is a tombstone every peer honours, and the bridges retract | |
| 2099 | * the copies they published: bsky's record is deleted and the fediverse is sent a Delete. What it cannot | |
| 2100 | * do is recall copies somebody else already took — a screenshot, a server that ignores Delete — and a | |
| 2101 | * person should meet that before the button, as keet's own delete leaves the original in the log. | |
| 2102 | */ | |
| 2103 | export function deletePost (name, from = '/', message = null) { | |
| 2104 | const warn = message ? '<p class="warn">' + esc(message) + '</p>' : '' | |
| 2105 | return layout('delete post', warn + | |
| 2106 | '<h1>Delete this post?</h1>' + | |
| 2107 | '<p>It disappears from favela for everyone who holds your feed, and it is deleted from bsky and ' + | |
| 2108 | 'Mastodon (the fediverse) wherever it was published.</p>' + | |
| 2109 | '<p class="who">Copies other people or servers already took cannot be recalled.</p>' + | |
| 2110 | '<form method="post" action="/post/delete">' + | |
| 2111 | '<input type="hidden" name="name" value="' + esc(name) + '">' + | |
| 2112 | '<input type="hidden" name="from" value="' + esc(from) + '">' + | |
| 2113 | '<button>delete it</button></form>' + | |
| 2114 | '<p><a href="' + esc(from) + '">keep it</a></p>') | |
| 2115 | } | |
| 2116 | ||
| 2090 | 2117 | /** |
| 2091 | 2118 | * Ask a bridge to carry this identity — and put what it grants on-chain. |
| 2092 | 2119 | * |
| … | … | @@ -1126,6 +1126,14 @@ export async function route (req, res, ctx) { |
| 1126 | 1126 | return seeOther(res, '/settings') |
| 1127 | 1127 | } |
| 1128 | 1128 | |
| 1129 | // ⛔ ASKS FIRST. The delete reaches bsky and the fediverse, so the post's link lands here and only | |
| 1130 | // this page's form deletes. Only the record's NAME travels on: the daemon looks it up in MY feed. | |
| 1131 | if (req.method === 'GET' && path === '/delete') { | |
| 1132 | const t = parseTarget(url.searchParams.get('target')) | |
| 1133 | if (!t) return seeOther(res, '/') | |
| 1134 | return send(res, pages.deletePost(t.name, local(url.searchParams.get('from')), url.searchParams.get('m'))) | |
| 1135 | } | |
| 1136 | ||
| 1129 | 1137 | if (req.method === 'GET' && path === '/knocks') { |
| 1130 | 1138 | return send(res, pages.knocks(await client.call('knocks'))) |
| 1131 | 1139 | } |
| … | … | @@ -1194,6 +1202,16 @@ export async function route (req, res, ctx) { |
| 1194 | 1202 | if (req.method === 'POST') { |
| 1195 | 1203 | const form = await body(req) |
| 1196 | 1204 | |
| 1205 | if (path === '/post/delete') { | |
| 1206 | const back = local(form.from) | |
| 1207 | try { | |
| 1208 | await client.call('post-delete', { name: String(form.name || '') }) | |
| 1209 | } catch (err) { | |
| 1210 | const page = back.split('#')[0] | |
| 1211 | return seeOther(res, page + (page.includes('?') ? '&' : '?') + 'm=' + encodeURIComponent(err.message)) | |
| 1212 | } | |
| 1213 | return seeOther(res, back) | |
| 1214 | } | |
| 1197 | 1215 | if (path === '/follow') { |
| 1198 | 1216 | if (/^[0-9a-f]{64}$/i.test(form.feed || '')) await client.call('follow', { feed: form.feed }) |
| 1199 | 1217 | return seeOther(res, '/following') |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-surface", |
| 3 | "version": "0.0.68", | |
| 3 | "version": "0.0.69", | |
| 4 | 4 | "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -915,3 +915,11 @@ test('★ a bsky notification links to the actor\'s bsky profile and says bluesk |
| 915 | 915 | t.ok(/bluesky/.test(html), 'and says where it came from') |
| 916 | 916 | t.absent(/\/u\/did:/.test(html), 'never as a favela profile path') |
| 917 | 917 | }) |
| 918 | ||
| 919 | // ⛔ Delete is offered on MY posts and replies only — the daemon marks a row `own`. | |
| 920 | test('★ my own post offers delete; somebody else\'s does not', (t) => { | |
| 921 | const base = { author: 'ab'.repeat(32), collection: 'post', name: 'p1', body: 'hello', createdAt: '2026-10-14T00:00:00Z' } | |
| 922 | t.ok(post({ ...base, own: true }, {}).includes('href="/delete?target='), 'mine: a delete link (to the confirm page)') | |
| 923 | t.absent(post({ ...base, own: false }, {}).includes('/delete?'), 'theirs: none') | |
| 924 | t.absent(post(base, {}).includes('/delete?'), 'unknown ownership: none') | |
| 925 | }) |
| … | … | @@ -1717,6 +1717,47 @@ test('⛔ a missing passphrase never reaches the daemon', async (t) => { |
| 1717 | 1717 | t.absent(calls.find((x) => x.cmd === 'did-carry'), 'nothing permanent was attempted') |
| 1718 | 1718 | }) |
| 1719 | 1719 | |
| 1720 | // ── deleting my own post ───────────────────────────────────────────────────────────────────── | |
| 1721 | // | |
| 1722 | // ⛔ TWO STEPS, deliberately. The delete reaches bsky and the fediverse, and neither can be taken back | |
| 1723 | // there, so the link opens a page that says so and only its form deletes. A one-press button next to | |
| 1724 | // like is how a post gets deleted by the thumb that meant to like it. | |
| 1725 | ||
| 1726 | test('★ GET /delete asks first — a page with the consequences, and nothing deleted yet', async (t) => { | |
| 1727 | reset() | |
| 1728 | answers = {} | |
| 1729 | const target = 'ab'.repeat(32) + '/post/p1' | |
| 1730 | const r = await request('GET', '/delete?target=' + encodeURIComponent(target) + '&from=' + encodeURIComponent('/u/x'), { headers: { cookie } }) | |
| 1731 | t.is(r.status, 200) | |
| 1732 | t.ok(r.body.includes('action="/post/delete"'), 'the page holds the form that deletes') | |
| 1733 | t.ok(r.body.includes('name="name" value="p1"'), 'naming the record') | |
| 1734 | t.ok(/bsky/.test(r.body) && /Mastodon|fediverse/.test(r.body), 'and says where else it is removed from') | |
| 1735 | t.is(called('post-delete').length, 0, 'and looking at it deleted nothing') | |
| 1736 | }) | |
| 1737 | ||
| 1738 | test('★ POST /post/delete reaches the daemon and returns you where you were', async (t) => { | |
| 1739 | reset() | |
| 1740 | answers = { 'post-delete': { deleted: 'p1', collection: 'post' } } | |
| 1741 | const r = await request('POST', '/post/delete', { | |
| 1742 | body: 'name=p1&from=' + encodeURIComponent('/u/' + 'ab'.repeat(32)), | |
| 1743 | headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' } | |
| 1744 | }) | |
| 1745 | t.alike(called('post-delete').map((c) => c.args), [{ name: 'p1' }]) | |
| 1746 | t.is(r.status, 303) | |
| 1747 | t.is(String(r.headers.location), '/u/' + 'ab'.repeat(32)) | |
| 1748 | }) | |
| 1749 | ||
| 1750 | test('⛔ a refused delete is SAID, not swallowed', async (t) => { | |
| 1751 | reset() | |
| 1752 | answers = { 'post-delete': new Error('no post or reply of yours is named "zz"') } | |
| 1753 | const r = await request('POST', '/post/delete', { | |
| 1754 | body: 'name=zz&from=%2F', | |
| 1755 | headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' } | |
| 1756 | }) | |
| 1757 | t.is(r.status, 303) | |
| 1758 | t.ok(/[?&]m=/.test(String(r.headers.location)), 'the reason travels to the page') | |
| 1759 | }) | |
| 1760 | ||
| 1720 | 1761 | test('shut down', async (t) => { |
| 1721 | 1762 | await new Promise((r) => server.close(r)) |
| 1722 | 1763 | await new Promise((r) => daemon.close(r)) |