diff --git a/assets/controllers/nostr/nostr_publish_controller.js b/assets/controllers/nostr/nostr_publish_controller.js index 1e505df..b3fb3b2 100644 --- a/assets/controllers/nostr/nostr_publish_controller.js +++ b/assets/controllers/nostr/nostr_publish_controller.js @@ -248,14 +248,19 @@ export default class extends Controller { this.showStatus('Publishing article...'); // Send to backend - await this.sendToBackend(signedEvent, this.collectFormData()); + const result = await this.sendToBackend(signedEvent, this.collectFormData()); - this.showSuccess('Article published successfully!'); - - // Optionally redirect after successful publish - setTimeout(() => { - window.location.href = `/article/d/${encodeURIComponent(nostrEvent.tags?.find(t => t[0] === 'd')?.[1] || '')}`; - }, 2000); + // Show appropriate success message + if (result.isDraft) { + this.showSuccess('Draft saved successfully!'); + // Stay on editor page for drafts - just show success message + } else { + this.showSuccess('Article published successfully!'); + // Redirect to article page after short delay + setTimeout(() => { + window.location.href = `/article/d/${encodeURIComponent(result.slug || nostrEvent.tags?.find(t => t[0] === 'd')?.[1] || '')}`; + }, 2000); + } } catch (error) { console.error('Publishing error:', error); @@ -493,7 +498,14 @@ export default class extends Controller { throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); } - return await response.json(); + const result = await response.json(); + + // Display relay results if available + if (result.relayResults) { + this.displayRelayResults(result.relayResults); + } + + return result; } generateSlug(title) { @@ -508,6 +520,81 @@ export default class extends Controller { .replace(/^-|-$/g, ''); } + displayRelayResults(relayResults) { + console.log('[nostr-publish] Relay results:', relayResults); + + // Handle error case + if (relayResults.error) { + window.showToast(`Relay error: ${relayResults.error}`, 'warning', 6000); + return; + } + + // Parse relay results from backend + if (!Array.isArray(relayResults) || relayResults.length === 0) { + window.showToast('Published to relays (status unknown)', 'info', 4000); + return; + } + + // Count successes, failures, and unknowns + let successCount = 0; + let failureCount = 0; + let unknownCount = 0; + const failedRelays = []; + const unknownRelays = []; + + relayResults.forEach((result) => { + const relayUrl = result.relay || 'Unknown relay'; + + if (result.success) { + successCount++; + } else if (result.type === 'auth') { + // AUTH responses are unknown status - not confirmed but not failed + unknownCount++; + unknownRelays.push(relayUrl); + } else { + // Other non-success responses are failures + failureCount++; + failedRelays.push(relayUrl); + } + }); + + // Show summary toast + if (successCount > 0 && failureCount === 0 && unknownCount === 0) { + // Perfect success + window.showToast(`✓ Published to ${successCount} relay${successCount > 1 ? 's' : ''}`, 'success', 5000); + } else if (successCount > 0 && unknownCount > 0 && failureCount === 0) { + // Success with some unknowns + window.showToast(`✓ Published to ${successCount} relay${successCount > 1 ? 's' : ''}, ${unknownCount} unknown`, 'success', 6000); + // Show details about unknowns + if (unknownRelays.length > 0) { + setTimeout(() => { + window.showToast(`Unknown status: ${unknownRelays.join(', ')}`, 'info', 8000); + }, 500); + } + } else if (successCount > 0 && failureCount > 0) { + // Mixed success and failure + const statusParts = [`${successCount} success`]; + if (unknownCount > 0) statusParts.push(`${unknownCount} unknown`); + statusParts.push(`${failureCount} failed`); + + window.showToast(`Published: ${statusParts.join(', ')}`, 'warning', 6000); + // Show details about failures + setTimeout(() => { + if (failedRelays.length > 0) { + window.showToast(`Failed: ${failedRelays.join(', ')}`, 'danger', 8000); + } + if (unknownRelays.length > 0) { + setTimeout(() => { + window.showToast(`Unknown: ${unknownRelays.join(', ')}`, 'info', 8000); + }, 500); + } + }, 500); + } else if (failureCount > 0 || unknownCount > 0) { + // No successes + window.showToast(`✗ Publishing uncertain (${unknownCount} unknown, ${failureCount} failed)`, 'warning', 8000); + } + } + showStatus(message) { // Use toast system if available, otherwise fallback to status target if (typeof window.showToast === 'function') { diff --git a/assets/controllers/nostr/nostr_single_sign_controller.js b/assets/controllers/nostr/nostr_single_sign_controller.js index 760afe5..a4bbff6 100644 --- a/assets/controllers/nostr/nostr_single_sign_controller.js +++ b/assets/controllers/nostr/nostr_single_sign_controller.js @@ -138,16 +138,21 @@ export default class extends Controller { console.log('[nostr_single_sign] Event signed successfully:', signed); this.showStatus('Publishing…'); - await this.publishSigned(signed); + const result = await this.publishSigned(signed); console.log('[nostr_single_sign] Event published successfully'); - this.showSuccess('Published successfully! Redirecting...'); - - // Redirect to reading list index after successful publish (only for form-based usage) - if (this.hasPublishButtonTarget) { - setTimeout(() => { - window.location.href = '/reading-list'; - }, 1500); + // Handle redirect based on whether it's a draft or published article + if (result.isDraft) { + this.showSuccess('Draft saved successfully!'); + // Stay on current page for drafts + } else { + this.showSuccess('Published successfully! Redirecting...'); + // Redirect to reading list or article page after successful publish + if (this.hasPublishButtonTarget) { + setTimeout(() => { + window.location.href = '/reading-list'; + }, 1500); + } } } catch (e) { console.error('[nostr_single_sign] Error during sign/publish:', e); @@ -178,7 +183,15 @@ export default class extends Controller { const data = await res.json().catch(() => ({})); throw new Error(data.error || `HTTP ${res.status}`); } - return res.json(); + + const result = await res.json(); + + // Display relay results if available + if (result.relayResults) { + this.displayRelayResults(result.relayResults); + } + + return result; } ensureCreatedAt(evt) { @@ -188,6 +201,81 @@ export default class extends Controller { if (typeof evt.content !== 'string') evt.content = ''; } + displayRelayResults(relayResults) { + console.log('[nostr_single_sign] Relay results:', relayResults); + + // Handle error case + if (relayResults.error) { + window.showToast(`Relay error: ${relayResults.error}`, 'warning', 6000); + return; + } + + // Parse relay results from backend + if (!Array.isArray(relayResults) || relayResults.length === 0) { + window.showToast('Published to relays (status unknown)', 'info', 4000); + return; + } + + // Count successes, failures, and unknowns + let successCount = 0; + let failureCount = 0; + let unknownCount = 0; + const failedRelays = []; + const unknownRelays = []; + + relayResults.forEach((result) => { + const relayUrl = result.relay || 'Unknown relay'; + + if (result.success) { + successCount++; + } else if (result.type === 'auth') { + // AUTH responses are unknown status - not confirmed but not failed + unknownCount++; + unknownRelays.push(relayUrl); + } else { + // Other non-success responses are failures + failureCount++; + failedRelays.push(relayUrl); + } + }); + + // Show summary toast + if (successCount > 0 && failureCount === 0 && unknownCount === 0) { + // Perfect success + window.showToast(`✓ Published to ${successCount} relay${successCount > 1 ? 's' : ''}`, 'success', 5000); + } else if (successCount > 0 && unknownCount > 0 && failureCount === 0) { + // Success with some unknowns + window.showToast(`✓ Published to ${successCount} relay${successCount > 1 ? 's' : ''}, ${unknownCount} unknown`, 'success', 6000); + // Show details about unknowns + if (unknownRelays.length > 0) { + setTimeout(() => { + window.showToast(`Unknown status: ${unknownRelays.join(', ')}`, 'info', 8000); + }, 500); + } + } else if (successCount > 0 && failureCount > 0) { + // Mixed success and failure + const statusParts = [`${successCount} success`]; + if (unknownCount > 0) statusParts.push(`${unknownCount} unknown`); + statusParts.push(`${failureCount} failed`); + + window.showToast(`Published: ${statusParts.join(', ')}`, 'warning', 6000); + // Show details about failures + setTimeout(() => { + if (failedRelays.length > 0) { + window.showToast(`Failed: ${failedRelays.join(', ')}`, 'danger', 8000); + } + if (unknownRelays.length > 0) { + setTimeout(() => { + window.showToast(`Unknown: ${unknownRelays.join(', ')}`, 'info', 8000); + }, 500); + } + }, 500); + } else if (failureCount > 0 || unknownCount > 0) { + // No successes + window.showToast(`✗ Publishing uncertain (${unknownCount} unknown, ${failureCount} failed)`, 'warning', 8000); + } + } + showStatus(message) { // Use toast system if available, otherwise fallback to status target if (typeof window.showToast === 'function') { diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index bcc3bd2..d085d86 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -2,13 +2,12 @@ namespace App\Controller; -use App\Dto\AdvancedMetadata; use App\Entity\Article; +use App\Entity\User; use App\Enum\KindsEnum; use App\Form\EditorType; use App\Service\HighlightService; use App\Service\NostrClient; -use App\Service\Nostr\NostrEventBuilder; use App\Service\Nostr\NostrEventParser; use App\Service\RedisCacheService; use App\Service\RedisViewStore; @@ -25,9 +24,6 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use App\ReadModel\RedisView\RedisReadingListView; -use App\ReadModel\RedisView\RedisBaseObject; -use App\ReadModel\RedisView\RedisArticleView; class ArticleController extends AbstractController { @@ -434,19 +430,46 @@ class ArticleController extends AbstractController $cacheKey = 'article_' . $article->getEventId(); $articlesCache->delete($cacheKey); + /** @var User $user */ + $user = $this->getUser(); + $relays = []; + if ($user) { + $relays = $user->getRelays(); + } + // Publish to Nostr relays + $relayResults = []; try { - $nostrClient->publishEvent($eventObj, []); + $rawResults = $nostrClient->publishEvent($eventObj, $relays); + $logger->info('Published to Nostr relays', [ + 'event_id' => $eventObj->getId(), + 'results' => $rawResults + ]); + + // Transform relay results into a simpler format for frontend + $relayResults = $this->transformRelayResults($rawResults); + } catch (\Exception $e) { // Log error but don't fail the request - article is saved locally - error_log('Failed to publish to Nostr relays: ' . $e->getMessage()); + $logger->error('Failed to publish to Nostr relays', [ + 'error' => $e->getMessage(), + 'event_id' => $eventObj->getId() + ]); + $relayResults = [ + 'error' => $e->getMessage() + ]; } + // Determine if this is a draft or published article + $isDraft = ($signedEvent['kind'] === KindsEnum::LONGFORM_DRAFT->value); + return new JsonResponse([ 'success' => true, - 'message' => 'Article published successfully', + 'message' => $isDraft ? 'Draft saved successfully' : 'Article published successfully', 'articleId' => $article->getId(), - 'slug' => $article->getSlug() + 'slug' => $article->getSlug(), + 'isDraft' => $isDraft, + 'relayResults' => $relayResults ]); } catch (\Exception $e) { @@ -456,6 +479,55 @@ class ArticleController extends AbstractController } } + /** + * Transform relay response objects into a simple array format for frontend + */ + private function transformRelayResults(array $rawResults): array + { + $results = []; + + foreach ($rawResults as $relayUrl => $response) { + $result = [ + 'relay' => $relayUrl, + 'success' => false, + 'type' => 'unknown', + 'message' => '' + ]; + + // Check if it's a RelayResponse object with accessible properties + if (is_object($response)) { + // RelayResponseOk - indicates successful publish + if (isset($response->type) && $response->type === 'OK') { + $result['success'] = true; + $result['type'] = 'ok'; + $result['message'] = $response->message ?? ''; + } + // RelayResponseAuth - relay requires auth (not necessarily a failure) + elseif (isset($response->type) && $response->type === 'AUTH') { + $result['success'] = false; // Not confirmed published + $result['type'] = 'auth'; + $result['message'] = 'Authentication required'; + } + // RelayResponseNotice - informational message + elseif (isset($response->type) && $response->type === 'NOTICE') { + $result['success'] = false; + $result['type'] = 'notice'; + $result['message'] = $response->message ?? ''; + } + // Check isSuccess property if available + elseif (isset($response->isSuccess)) { + $result['success'] = (bool)$response->isSuccess; + $result['type'] = $response->type ?? 'unknown'; + $result['message'] = $response->message ?? ''; + } + } + + $results[] = $result; + } + + return $results; + } + private function validateNostrEvent(array $event): void { $requiredFields = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig']; diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php index 94a2b1e..34e05f4 100644 --- a/src/Controller/AuthorController.php +++ b/src/Controller/AuthorController.php @@ -282,8 +282,13 @@ class AuthorController extends AbstractController $latest = isset($articles[0]['article']['publishedAt']) ? strtotime($articles[0]['article']['publishedAt']) : time(); - } else { + } else if ($articles[0] instanceof Article) { + // Article entity $latest = $articles[0]->getCreatedAt()->getTimestamp(); + } else { + // Fallback + // Something went wrong upstream, use current time + $latest = time(); } // Dispatch async message to fetch new articles since latest + 1 $messageBus->dispatch(new FetchAuthorArticlesMessage($pubkey, $latest + 1)); diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 19fdac1..707110d 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -118,7 +118,7 @@ class NostrClient $authorRelays = array_filter($authorRelays, function ($relay) { return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost'); }); - return array_slice($authorRelays, 0, $limit); + return $limit != 0 ? array_slice($authorRelays, 0, $limit) : $authorRelays; } return $reputableAuthorRelays; @@ -167,7 +167,7 @@ class NostrClient // If no relays, fetch relays for user then post to those if (empty($relays)) { $key = new Key(); - $relays = $this->getTopReputableRelaysForAuthor($key->convertPublicKeyToBech32($event->getPublicKey()), 5); + $relays = $this->getTopReputableRelaysForAuthor($key->convertPublicKeyToBech32($event->getPublicKey()), 0); } else { // Ensure local relay is included when publishing $relays = $this->relayPool->ensureLocalRelayInList($relays);