Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions swa/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
});
Expand Down
31 changes: 27 additions & 4 deletions swa/feedView.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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…';
Expand All @@ -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;
Expand All @@ -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 = '';
Expand Down Expand Up @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions swa/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ <h2>Direct Messages</h2>
<section id="post-panel" class="panel">
<h2>Post</h2>
<div class="field-group">
<input id="post-subject" type="text" placeholder="Subject (optional)" maxlength="80" autocomplete="off">
<textarea id="post-content" rows="4" placeholder="What's on your mind?" maxlength="2000"></textarea>
<div class="char-counter"><span id="char-count">0</span> / 2000</div>
</div>
Expand Down
8 changes: 8 additions & 0 deletions swa/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions swa/threading.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
38 changes: 37 additions & 1 deletion swa/threading.test.js
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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');
});