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

<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>