You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
320 lines
9.8 KiB
320 lines
9.8 KiB
<script lang="ts"> |
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
|
import { signAndPublish } from '../../services/nostr/auth-handler.js'; |
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
import { fetchRelayLists } from '../../services/user-data.js'; |
|
import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte'; |
|
import GifPicker from '../../components/content/GifPicker.svelte'; |
|
import EmojiPicker from '../../components/content/EmojiPicker.svelte'; |
|
import { insertTextAtCursor } from '../../services/text-utils.js'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
import { KIND } from '../../types/kind-lookup.js'; |
|
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js'; |
|
|
|
interface Props { |
|
threadId: string; // The root event ID |
|
rootEvent?: NostrEvent; // The root event (to determine reply kind) |
|
parentEvent?: NostrEvent; // If replying to a comment/reply |
|
onPublished?: () => void; |
|
onCancel?: () => void; |
|
} |
|
|
|
let { threadId, rootEvent, parentEvent, onPublished, onCancel }: Props = $props(); |
|
|
|
let content = $state(''); |
|
let publishing = $state(false); |
|
let showStatusModal = $state(false); |
|
let publicationResults: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = $state(null); |
|
let showGifPicker = $state(false); |
|
let showEmojiPicker = $state(false); |
|
let textareaRef: HTMLTextAreaElement | null = $state(null); |
|
const isLoggedIn = $derived(sessionManager.isLoggedIn()); |
|
|
|
// Only show GIF/emoji buttons for non-kind-11 events (kind 1 replies and kind 1111 comments) |
|
const showGifButton = $derived.by(() => { |
|
const replyKind = getReplyKind(); |
|
return replyKind === 1 || replyKind === 1111; |
|
}); |
|
|
|
/** |
|
* Determine what kind of reply to create |
|
* - Kind 1 events should get kind 1 replies |
|
* - Everything else gets kind 1111 comments |
|
*/ |
|
function getReplyKind(): number { |
|
// If replying to a parent event, check its kind |
|
if (parentEvent) { |
|
// If parent is kind 1, reply with kind 1 |
|
if (parentEvent.kind === KIND.SHORT_TEXT_NOTE) return KIND.SHORT_TEXT_NOTE; |
|
// Everything else gets kind 1111 |
|
return KIND.COMMENT; |
|
} |
|
|
|
// If replying to root, check root kind |
|
if (rootEvent) { |
|
// If root is kind 1, reply with kind 1 |
|
if (rootEvent.kind === KIND.SHORT_TEXT_NOTE) return KIND.SHORT_TEXT_NOTE; |
|
// Everything else gets kind 1111 |
|
return KIND.COMMENT; |
|
} |
|
|
|
// Default to kind 1111 if we can't determine |
|
return 1111; |
|
} |
|
|
|
async function publish() { |
|
if (!sessionManager.isLoggedIn()) { |
|
alert('Please log in to comment'); |
|
return; |
|
} |
|
|
|
if (!content.trim()) { |
|
alert('Comment cannot be empty'); |
|
return; |
|
} |
|
|
|
publishing = true; |
|
|
|
try { |
|
const replyKind = getReplyKind(); |
|
const tags: string[][] = []; |
|
|
|
if (replyKind === 1) { |
|
// Kind 1 reply (NIP-10) |
|
tags.push(['e', threadId]); // Root event |
|
if (rootEvent) { |
|
tags.push(['p', rootEvent.pubkey]); // Root author |
|
} |
|
|
|
// If replying to a parent, add parent references |
|
if (parentEvent) { |
|
tags.push(['e', parentEvent.id, '', 'reply']); // Parent event with 'reply' marker |
|
tags.push(['p', parentEvent.pubkey]); // Parent author |
|
} |
|
} else { |
|
// Kind 1111 comment (NIP-22) |
|
const rootKind = rootEvent?.kind || '1'; |
|
tags.push(['K', String(rootKind)]); // Root kind |
|
tags.push(['E', threadId]); // Root event ID (uppercase for NIP-22) |
|
if (rootEvent) { |
|
tags.push(['P', rootEvent.pubkey]); // Root author (uppercase P) |
|
} |
|
|
|
// If replying to a parent, add parent references |
|
if (parentEvent) { |
|
const parentKind = parentEvent.kind; |
|
tags.push(['e', parentEvent.id]); // Parent event ID (lowercase for parent) |
|
tags.push(['k', String(parentKind)]); // Parent kind (lowercase k) |
|
tags.push(['p', parentEvent.pubkey]); // Parent author (lowercase p) |
|
// Also add uppercase for compatibility |
|
tags.push(['E', parentEvent.id]); |
|
tags.push(['P', parentEvent.pubkey]); |
|
} |
|
} |
|
|
|
if (shouldIncludeClientTag()) { |
|
tags.push(['client', 'Aitherboard']); |
|
} |
|
|
|
const event: Omit<NostrEvent, 'id' | 'sig'> = { |
|
kind: replyKind, |
|
pubkey: sessionManager.getCurrentPubkey()!, |
|
created_at: Math.floor(Date.now() / 1000), |
|
tags, |
|
content: content.trim() |
|
}; |
|
|
|
// Get target's inbox if replying to someone |
|
let targetInbox: string[] | undefined; |
|
const targetPubkey = parentEvent?.pubkey || rootEvent?.pubkey; |
|
if (targetPubkey && targetPubkey !== sessionManager.getCurrentPubkey()) { |
|
try { |
|
const { inbox } = await fetchRelayLists(targetPubkey); |
|
targetInbox = inbox; |
|
} catch (error) { |
|
console.warn('Failed to fetch target inbox relays:', error); |
|
// Continue without target inbox |
|
} |
|
} |
|
|
|
// Use proper relay selection for comments |
|
const publishRelays = relayManager.getCommentPublishRelays(targetInbox); |
|
const result = await signAndPublish(event, publishRelays); |
|
|
|
// Show publication status modal |
|
publicationResults = result; |
|
showStatusModal = true; |
|
|
|
if (result.success.length > 0) { |
|
content = ''; |
|
onPublished?.(); |
|
} |
|
} catch (error) { |
|
console.error('Error publishing comment:', error); |
|
// Show error in modal if possible, otherwise alert |
|
const errorMessage = error instanceof Error ? error.message : String(error); |
|
publicationResults = { |
|
success: [], |
|
failed: [{ relay: 'Unknown', error: errorMessage }] |
|
}; |
|
showStatusModal = true; |
|
} finally { |
|
publishing = false; |
|
} |
|
} |
|
|
|
function handleGifSelect(gifUrl: string) { |
|
if (!textareaRef) return; |
|
// Insert GIF URL as plain text |
|
insertTextAtCursor(textareaRef, gifUrl); |
|
showGifPicker = false; |
|
} |
|
|
|
function handleEmojiSelect(emoji: string) { |
|
if (!textareaRef) return; |
|
insertTextAtCursor(textareaRef, emoji); |
|
showEmojiPicker = false; |
|
} |
|
</script> |
|
|
|
{#if isLoggedIn} |
|
<div class="comment-form"> |
|
<div class="textarea-wrapper"> |
|
<textarea |
|
bind:this={textareaRef} |
|
bind:value={content} |
|
placeholder={parentEvent ? 'Write a reply...' : 'Write a comment...'} |
|
class="w-full p-3 border border-fog-border dark:border-fog-dark-border rounded bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text" |
|
rows="4" |
|
disabled={publishing} |
|
></textarea> |
|
|
|
{#if showGifButton} |
|
<div class="textarea-buttons"> |
|
<button |
|
type="button" |
|
onclick={() => { |
|
showGifPicker = !showGifPicker; |
|
showEmojiPicker = false; |
|
}} |
|
class="toolbar-button" |
|
title="Insert GIF" |
|
aria-label="Insert GIF" |
|
disabled={publishing} |
|
> |
|
GIF |
|
</button> |
|
<button |
|
type="button" |
|
onclick={() => { showEmojiPicker = !showEmojiPicker; showGifPicker = false; }} |
|
class="toolbar-button" |
|
title="Insert emoji" |
|
aria-label="Insert emoji" |
|
disabled={publishing} |
|
> |
|
😀 |
|
</button> |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<div class="flex items-center justify-end mt-2"> |
|
<div class="flex gap-2"> |
|
{#if onCancel} |
|
<button |
|
onclick={onCancel} |
|
class="px-4 py-2 text-sm border border-fog-border dark:border-fog-dark-border rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight" |
|
disabled={publishing} |
|
> |
|
Cancel |
|
</button> |
|
{/if} |
|
<button |
|
onclick={publish} |
|
class="px-4 py-2 text-sm bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90 disabled:opacity-50" |
|
disabled={publishing || !content.trim()} |
|
> |
|
{publishing ? 'Publishing...' : parentEvent ? 'Reply' : 'Comment'} |
|
</button> |
|
</div> |
|
</div> |
|
|
|
<PublicationStatusModal bind:open={showStatusModal} bind:results={publicationResults} /> |
|
|
|
{#if showGifButton} |
|
<GifPicker open={showGifPicker} onSelect={handleGifSelect} onClose={() => showGifPicker = false} /> |
|
<EmojiPicker open={showEmojiPicker} onSelect={handleEmojiSelect} onClose={() => showEmojiPicker = false} /> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
<style> |
|
.comment-form { |
|
margin-top: 1rem; |
|
} |
|
|
|
.textarea-wrapper { |
|
position: relative; |
|
} |
|
|
|
textarea { |
|
resize: vertical; |
|
min-height: 100px; |
|
} |
|
|
|
textarea:focus { |
|
outline: none; |
|
border-color: var(--fog-accent, #64748b); |
|
} |
|
|
|
.textarea-buttons { |
|
position: absolute; |
|
bottom: 0.5rem; |
|
left: 0.5rem; |
|
display: flex; |
|
gap: 0.25rem; |
|
z-index: 10; |
|
} |
|
|
|
.toolbar-button { |
|
padding: 0.25rem 0.5rem; |
|
font-size: 0.75rem; |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.25rem; |
|
background: var(--fog-post, #ffffff); |
|
color: var(--fog-text, #1f2937); |
|
cursor: pointer; |
|
transition: all 0.2s; |
|
} |
|
|
|
.toolbar-button:hover:not(:disabled) { |
|
background: var(--fog-highlight, #f3f4f6); |
|
border-color: var(--fog-accent, #64748b); |
|
} |
|
|
|
.toolbar-button:disabled { |
|
opacity: 0.5; |
|
cursor: not-allowed; |
|
} |
|
|
|
:global(.dark) .toolbar-button { |
|
background: var(--fog-dark-post, #1f2937); |
|
border-color: var(--fog-dark-border, #374151); |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
:global(.dark) .toolbar-button:hover:not(:disabled) { |
|
background: var(--fog-dark-highlight, #374151); |
|
border-color: var(--fog-dark-accent, #64748b); |
|
} |
|
|
|
.client-tag-checkbox { |
|
opacity: 0.7; |
|
cursor: pointer; |
|
} |
|
|
|
.client-tag-checkbox:hover { |
|
opacity: 0.9; |
|
} |
|
</style>
|
|
|