Browse Source

Comments on articles

imwald
Nuša Pukšič 3 months ago
parent
commit
fc94dbcdcb
  1. 170
      assets/controllers/nostr_comment_controller.js
  2. 4
      assets/styles/02-layout/header.css
  3. 23
      assets/styles/04-pages/forum.css
  4. 32
      src/Controller/CommentController.php
  5. 30
      src/Twig/Components/Organisms/CommentForm.php
  6. 18
      templates/components/Organisms/CommentForm.html.twig
  7. 18
      templates/pages/article.html.twig

170
assets/controllers/nostr_comment_controller.js

@ -0,0 +1,170 @@ @@ -0,0 +1,170 @@
import { Controller } from '@hotwired/stimulus';
// NIP-22 Comment Publishing Controller
// Usage: Attach to a form with data attributes for root/parent context
export default class extends Controller {
static targets = ['publishButton', 'status'];
static values = {
publishUrl: String,
csrfToken: String
};
connect() {
console.log('Nostr comment controller connected');
try {
console.debug('[nostr-comment] publishUrl:', this.publishUrlValue || '(none)');
console.debug('[nostr-comment] has csrfToken:', Boolean(this.csrfTokenValue));
} catch (_) {}
}
async publish(event) {
event.preventDefault();
if (!this.publishUrlValue) {
this.showError('Publish URL is not configured');
return;
}
if (!this.csrfTokenValue) {
this.showError('Missing CSRF token');
return;
}
if (!window.nostr) {
this.showError('Nostr extension not found');
return;
}
this.publishButtonTarget.disabled = true;
this.showStatus('Preparing comment for signing...');
try {
// Collect form data and context
const formData = this.collectFormData();
// Validate required fields
if (!formData.content) {
throw new Error('Comment content is required');
}
if (!formData.root || !formData.parent) {
throw new Error('Missing root or parent context');
}
if (!this.isPlaintext(formData.content)) {
throw new Error('Comment must be plaintext (no formatting)');
}
// Create NIP-22 event
const nostrEvent = await this.createNip22Event(formData);
this.showStatus('Requesting signature from Nostr extension...');
const signedEvent = await window.nostr.signEvent(nostrEvent);
this.showStatus('Publishing comment...');
await this.sendToBackend(signedEvent, formData);
this.showSuccess('Comment published successfully!');
// Optionally reload or clear form
setTimeout(() => {
window.location.reload();
}, 1500);
} catch (error) {
console.error('Publishing error:', error);
this.showError(`Publishing failed: ${error.message}`);
} finally {
this.publishButtonTarget.disabled = false;
}
}
collectFormData() {
// Use the form element directly (this.element is the <form>)
const form = this.element;
if (!form) {
throw new Error('Form element not found');
}
const formData = new FormData(form);
// Comment content (plaintext only)
const content = (formData.get('comment[content]') || '').trim();
// Root and parent context (JSON in hidden fields or data attributes)
let root, parent;
try {
root = JSON.parse(formData.get('comment[root]') || this.element.dataset.root || '{}');
parent = JSON.parse(formData.get('comment[parent]') || this.element.dataset.parent || '{}');
} catch (_) {
throw new Error('Invalid root/parent context');
}
return { content, root, parent };
}
isPlaintext(text) {
// No HTML tags, no Markdown formatting
return !(/[<>\*\_\`\[\]#]/.test(text));
}
async createNip22Event({ content, root, parent }) {
// Get user's public key
const pubkey = await window.nostr.getPublicKey();
const created_at = Math.floor(Date.now() / 1000);
// Build tags according to NIP-22
const tags = [];
// Root tags (uppercase)
if (root.tag && root.value) {
const rootTag = [root.tag.toUpperCase(), root.value];
if (root.relay) rootTag.push(root.relay);
if (root.pubkey) rootTag.push(root.pubkey);
tags.push(rootTag);
}
if (root.kind) tags.push(['K', String(root.kind)]);
if (root.pubkey) tags.push(['P', root.pubkey, root.relay || '']);
// Parent tags (lowercase)
if (parent.tag && parent.value) {
const parentTag = [parent.tag.toLowerCase(), parent.value];
if (parent.relay) parentTag.push(parent.relay);
if (parent.pubkey) parentTag.push(parent.pubkey);
tags.push(parentTag);
}
if (parent.kind) tags.push(['k', String(parent.kind)]);
if (parent.pubkey) tags.push(['p', parent.pubkey, parent.relay || '']);
// NIP-22 event
return {
kind: 1111,
created_at,
tags,
content,
pubkey
};
}
async sendToBackend(signedEvent, formData) {
const response = await fetch(this.publishUrlValue, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': this.csrfTokenValue
},
body: JSON.stringify({
event: signedEvent,
formData: formData
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
}
showStatus(message) {
if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `<div class="alert alert-info">${message}</div>`;
}
}
showSuccess(message) {
if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `<div class="alert alert-success">${message}</div>`;
}
}
showError(message) {
if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `<div class="alert alert-danger">${message}</div>`;
}
}
}

4
assets/styles/02-layout/header.css

@ -66,6 +66,10 @@ @@ -66,6 +66,10 @@
margin: 10px 0;
}
.header__logo {
margin-left: var(--spacing-3);
}
.header__image {
position: relative;
width: 100%;

23
assets/styles/04-pages/forum.css

@ -60,22 +60,21 @@ @@ -60,22 +60,21 @@
margin: 1rem 0;
}
@media (max-width: 960px) {
.subcategories-grid .tags {
justify-content: flex-start;
}
.subcategories-grid .tags {
justify-content: flex-start;
}
.subcategories-grid .tag {
padding: 0;
background-color: transparent;
color: var(--color-primary);
}
.subcategories-grid .tag {
padding: 0;
background-color: transparent;
color: var(--color-primary);
}
.subcategories-grid .tag:before {
content: '#';
}
.subcategories-grid .tag:before {
content: '#';
}
.sub-card {
border: 1px solid var(--color-primary);
background: #fff;

32
src/Controller/CommentController.php

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
class CommentController extends AbstractController
{
#[Route('/comments/publish', name: 'comment_publish', methods: ['POST'])]
public function publish(Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
{
// CSRF check
$csrfToken = $request->headers->get('X-CSRF-TOKEN');
if (!$csrfToken || !$csrfTokenManager->isTokenValid(new \Symfony\Component\Security\Csrf\CsrfToken('comment_publish', $csrfToken))) {
return new JsonResponse(['message' => 'Invalid CSRF token'], 403);
}
$data = json_decode($request->getContent(), true);
if (!$data || !isset($data['event'])) {
return new JsonResponse(['message' => 'Invalid request'], 400);
}
// Here you would validate and process the NIP-22 event
// For now, just return success for integration testing
return new JsonResponse(['status' => 'ok']);
}
}

30
src/Twig/Components/Organisms/CommentForm.php

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
<?php
namespace App\Twig\Components\Organisms;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
class CommentForm
{
public string $publish_url;
public string $csrf_token;
public array $root_context = [];
public array $parent_context = [];
public string $form_id;
public function mount(
string $publish_url,
string $csrf_token,
array $root_context,
array $parent_context,
?string $form_id = null
): void {
$this->publish_url = $publish_url;
$this->csrf_token = $csrf_token;
$this->root_context = $root_context;
$this->parent_context = $parent_context;
$this->form_id = $form_id ?? uniqid('nip22_comment_');
}
}

18
templates/components/Organisms/CommentForm.html.twig

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
<form data-controller="nostr-comment"
data-nostr-comment-publish-url-value="{{ publish_url }}"
data-nostr-comment-csrf-token-value="{{ csrf_token }}"
data-nostr-comment-root="{{ root_context|json_encode|e('html_attr') }}"
data-nostr-comment-parent="{{ parent_context|json_encode|e('html_attr') }}"
data-action="submit->nostr-comment#publish"
class="nip22-comment-form">
<div data-nostr-comment-target="status"></div>
<div class="mb-2">
<label for="comment_content_{{ form_id }}" class="form-label hidden">Comment</label>
<textarea id="comment_content_{{ form_id }}" name="comment[content]" class="form-control" rows="3" required placeholder="Write your comment"></textarea>
</div>
<input type="hidden" name="comment[root]" value='{{ root_context|json_encode|e('html_attr') }}'>
<input type="hidden" name="comment[parent]" value='{{ parent_context|json_encode|e('html_attr') }}'>
<div class="actions">
<button type="submit" data-nostr-comment-target="publishButton" class="btn btn-primary mt-2">Publish Comment</button>
</div>
</form>

18
templates/pages/article.html.twig

@ -82,6 +82,24 @@ @@ -82,6 +82,24 @@
</div>
<twig:Organisms:CommentForm
:publish_url="path('comment_publish')"
:csrf_token="csrf_token('comment_publish')"
:root_context="{
tag: 'A',
value: '30023:' ~ article.pubkey ~ ':' ~ article.slug,
relay: null,
pubkey: article.pubkey,
kind: 30023
}"
:parent_context="{
tag: 'a',
value: '30023:' ~ article.pubkey ~ ':' ~ article.slug,
relay: null,
pubkey: article.pubkey,
kind: 30023
}" />
<twig:Organisms:Comments current="30023:{{ article.pubkey }}:{{ article.slug|e }}"></twig:Organisms:Comments>
</article>
{% endblock %}

Loading…
Cancel
Save