From 487e5d21ec7d9cf54a9f9ba9b9fc0e79da8d3d51 Mon Sep 17 00:00:00 2001 From: Ryback2501 Date: Thu, 18 Jun 2026 15:44:05 +0200 Subject: [PATCH] feat(swa): add subject tag to notes and replies --- swa/app.js | 16 +++++++++++----- swa/feedView.js | 31 +++++++++++++++++++++++++++---- swa/index.html | 1 + swa/style.css | 8 ++++++++ swa/threading.js | 9 +++++++++ swa/threading.test.js | 38 +++++++++++++++++++++++++++++++++++++- 6 files changed, 93 insertions(+), 10 deletions(-) diff --git a/swa/app.js b/swa/app.js index 715f979..1a83490 100644 --- a/swa/app.js +++ b/swa/app.js @@ -57,6 +57,7 @@ const followsStatus = document.getElementById('follows-status'); const followsList = document.getElementById('follows-list'); const postContent = document.getElementById('post-content'); +const postSubject = document.getElementById('post-subject'); const charCount = document.getElementById('char-count'); const postBtn = document.getElementById('post-btn'); const postResult = document.getElementById('post-result'); @@ -770,11 +771,15 @@ postBtn.addEventListener('click', async () => { setPostResult(store.postDifficulty > 0 ? 'Mining proof of work…' : 'Publishing…', ''); try { - const { content: transformedContent, tags: mentionTags } = buildMentionEvent(content); - const event = await createOwnEvent({ kind: 1, tags: mentionTags, content: transformedContent, difficulty: store.postDifficulty }); + const subject = postSubject.value.trim(); + const subjectTags = subject ? [['subject', subject]] : []; + const { content: transformedContent, tags: mentionTags } = buildMentionEvent(content, subjectTags.length); + const tags = [...subjectTags, ...mentionTags]; + const event = await createOwnEvent({ kind: 1, tags, content: transformedContent, difficulty: store.postDifficulty }); await publishToAll(event); store.addEvent(event); postContent.value = ''; + postSubject.value = ''; charCount.textContent = '0'; setPostResult('Posted.', 'ok'); } catch (err) { @@ -1472,14 +1477,15 @@ function makeRenderCallbacks() { rerenderFeed(); try { await publishFollowList(); } catch { /* ignore relay error */ } }, - onReply: async (parentEvent, content) => { + onReply: async (parentEvent, content, subject) => { if (!store.signer) throw new Error('Generate or import a keypair first.'); if (!isAnyConnected()) throw new Error('Connect to a relay first.'); + const subjectTags = subject ? [['subject', subject]] : []; const replyTags = buildReplyTags(parentEvent, store.signer.pubkeyHex); - const { content: transformedContent, tags: mentionTags } = buildMentionEvent(content, replyTags.length); + const { content: transformedContent, tags: mentionTags } = buildMentionEvent(content, subjectTags.length + replyTags.length); const event = await createOwnEvent({ kind: 1, - tags: [...replyTags, ...mentionTags], + tags: [...subjectTags, ...replyTags, ...mentionTags], content: transformedContent, difficulty: store.postDifficulty, }); diff --git a/swa/feedView.js b/swa/feedView.js index 526d9bf..e1affd8 100644 --- a/swa/feedView.js +++ b/swa/feedView.js @@ -1,4 +1,4 @@ -import { resolveReplyTag } from './threading.js'; +import { resolveReplyTag, getSubject, adornReplySubject } from './threading.js'; import { getEventDifficulty } from './proofOfWork.js'; const OTS_VERIFY_URL = 'https://opentimestamps.org'; @@ -275,6 +275,14 @@ export function renderEvent(event, slice, callbacks) { actions.appendChild(deleteBtn); } + const subject = getSubject(event); + if (subject) { + const subjectEl = document.createElement('div'); + subjectEl.className = 'event-subject'; + subjectEl.textContent = subject; + card.appendChild(subjectEl); + } + card.append(content, actions); const replyForm = createReplyForm(event, displayName, onReply); @@ -316,6 +324,13 @@ function createReplyForm(parentEvent, displayName, onReply) { label.className = 'reply-form-label'; label.textContent = `Replying to ${displayName}`; + const subjectInput = document.createElement('input'); + subjectInput.type = 'text'; + subjectInput.className = 'reply-subject-input'; + subjectInput.placeholder = 'Subject (optional)'; + subjectInput.maxLength = 80; + subjectInput.value = adornReplySubject(getSubject(parentEvent)); + const textarea = document.createElement('textarea'); textarea.rows = 3; textarea.placeholder = 'Write your reply…'; @@ -334,7 +349,7 @@ function createReplyForm(parentEvent, displayName, onReply) { resultMsg.className = 'result-msg'; formActions.append(submitBtn, cancelBtn, resultMsg); - form.append(label, textarea, formActions); + form.append(label, subjectInput, textarea, formActions); cancelBtn.addEventListener('click', () => { form.hidden = true; @@ -351,7 +366,7 @@ function createReplyForm(parentEvent, displayName, onReply) { resultMsg.className = 'result-msg'; try { - await onReply(parentEvent, content); + await onReply(parentEvent, content, subjectInput.value.trim()); textarea.value = ''; form.hidden = true; resultMsg.textContent = ''; @@ -457,7 +472,15 @@ export function renderReply(event, slice) { content.className = 'event-content'; content.appendChild(renderMentionContent(event.content, event.tags, profiles)); - card.append(meta, content); + const subject = getSubject(event); + if (subject) { + const subjectEl = document.createElement('div'); + subjectEl.className = 'event-subject'; + subjectEl.textContent = subject; + card.append(meta, subjectEl, content); + } else { + card.append(meta, content); + } return card; } diff --git a/swa/index.html b/swa/index.html index 4b47331..a9d6d56 100644 --- a/swa/index.html +++ b/swa/index.html @@ -243,6 +243,7 @@

Direct Messages

Post

+
0 / 2000
diff --git a/swa/style.css b/swa/style.css index 71ba998..64554b0 100644 --- a/swa/style.css +++ b/swa/style.css @@ -770,6 +770,14 @@ details[open] > summary::before { line-height: 1.55; } +.event-subject { + font-size: 0.95rem; + font-weight: 600; + color: var(--text); + margin-bottom: 0.25rem; + word-break: break-word; +} + .mention { color: var(--accent); font-weight: 500; diff --git a/swa/threading.js b/swa/threading.js index 0488b75..30eac95 100644 --- a/swa/threading.js +++ b/swa/threading.js @@ -31,6 +31,15 @@ export function buildQuoteTag(quotedEvent, relayHint = '') { return ['q', quotedEvent.id, relayHint, quotedEvent.pubkey]; } +export function getSubject(event) { + return event.tags.find(t => t[0] === 'subject')?.[1] || ''; +} + +export function adornReplySubject(subject) { + if (!subject) return ''; + return /^re:/i.test(subject) ? subject : `Re: ${subject}`; +} + export function buildMentionEvent(content, tagOffset = 0, eventIds = new Set()) { const mentionTags = []; const seen = new Map(); diff --git a/swa/threading.test.js b/swa/threading.test.js index b697147..1b5f05c 100644 --- a/swa/threading.test.js +++ b/swa/threading.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { resolveReplyTag, buildReplyTags, buildMentionEvent, buildQuoteTag } from './threading.js'; +import { resolveReplyTag, buildReplyTags, buildMentionEvent, buildQuoteTag, getSubject, adornReplySubject } from './threading.js'; const HEX_A = 'a'.repeat(64); const HEX_B = 'b'.repeat(64); @@ -195,3 +195,39 @@ test('buildQuoteTag: includes provided relay hint', () => { const tag = buildQuoteTag(quoted, 'wss://relay.example.com'); assert.deepEqual(tag, ['q', HEX_A, 'wss://relay.example.com', HEX_B]); }); + +// ── getSubject (NIP-14) ─────────────────────────────────────────────────────── + +test('getSubject: returns the subject tag value', () => { + assert.equal(getSubject({ tags: [['subject', 'Hello world']] }), 'Hello world'); +}); + +test('getSubject: returns empty string when no subject tag', () => { + assert.equal(getSubject({ tags: [['e', HEX_A]] }), ''); +}); + +test('getSubject: returns empty string for empty tags', () => { + assert.equal(getSubject({ tags: [] }), ''); +}); + +test('getSubject: returns the first subject tag when several exist', () => { + assert.equal(getSubject({ tags: [['subject', 'first'], ['subject', 'second']] }), 'first'); +}); + +// ── adornReplySubject (NIP-14) ──────────────────────────────────────────────── + +test('adornReplySubject: returns empty string for empty input', () => { + assert.equal(adornReplySubject(''), ''); +}); + +test('adornReplySubject: prepends "Re: " to a plain subject', () => { + assert.equal(adornReplySubject('Lunch plans'), 'Re: Lunch plans'); +}); + +test('adornReplySubject: leaves an already-prefixed subject unchanged', () => { + assert.equal(adornReplySubject('Re: Lunch plans'), 'Re: Lunch plans'); +}); + +test('adornReplySubject: treats the prefix case-insensitively', () => { + assert.equal(adornReplySubject('RE: Lunch plans'), 'RE: Lunch plans'); +});