|
|
<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 { onMount } from 'svelte'; |
|
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
|
|
|
|
interface Props { |
|
|
event: NostrEvent; // The event to react to (kind 11 or 1111) |
|
|
} |
|
|
|
|
|
let { event }: Props = $props(); |
|
|
|
|
|
let reactions = $state<Map<string, { content: string; pubkeys: Set<string> }>>(new Map()); |
|
|
let userReaction = $state<string | null>(null); |
|
|
let loading = $state(true); |
|
|
|
|
|
onMount(async () => { |
|
|
await nostrClient.initialize(); |
|
|
loadReactions(); |
|
|
}); |
|
|
|
|
|
async function loadReactions() { |
|
|
loading = true; |
|
|
try { |
|
|
const config = nostrClient.getConfig(); |
|
|
|
|
|
// Fetch reactions (kind 7) for this event |
|
|
const filters = [ |
|
|
{ |
|
|
kinds: [7], |
|
|
'#e': [event.id] |
|
|
} |
|
|
]; |
|
|
|
|
|
const reactionEvents = await nostrClient.fetchEvents( |
|
|
filters, |
|
|
[...config.defaultRelays], |
|
|
{ useCache: true, cacheResults: true, onUpdate: (updated) => { |
|
|
processReactions(updated); |
|
|
}} |
|
|
); |
|
|
|
|
|
processReactions(reactionEvents); |
|
|
} catch (error) { |
|
|
console.error('Error loading reactions:', error); |
|
|
} finally { |
|
|
loading = false; |
|
|
} |
|
|
} |
|
|
|
|
|
function processReactions(reactionEvents: NostrEvent[]) { |
|
|
const reactionMap = new Map<string, { content: string; pubkeys: Set<string> }>(); |
|
|
const currentUser = sessionManager.getCurrentPubkey(); |
|
|
|
|
|
for (const reactionEvent of reactionEvents) { |
|
|
const content = reactionEvent.content.trim(); |
|
|
|
|
|
// Normalize reactions for kind 11/1111: only + and - allowed |
|
|
// Backward compatibility: ⬆️ = +, ⬇️ = - |
|
|
let normalizedContent = content; |
|
|
if (event.kind === 11 || event.kind === 1111) { |
|
|
if (content === '⬆️' || content === '↑') { |
|
|
normalizedContent = '+'; |
|
|
} else if (content === '⬇️' || content === '↓') { |
|
|
normalizedContent = '-'; |
|
|
} else if (content !== '+' && content !== '-') { |
|
|
continue; // Skip invalid reactions for threads/comments |
|
|
} |
|
|
} |
|
|
|
|
|
if (!reactionMap.has(normalizedContent)) { |
|
|
reactionMap.set(normalizedContent, { content: normalizedContent, pubkeys: new Set() }); |
|
|
} |
|
|
reactionMap.get(normalizedContent)!.pubkeys.add(reactionEvent.pubkey); |
|
|
|
|
|
// Track user's reaction |
|
|
if (currentUser && reactionEvent.pubkey === currentUser) { |
|
|
userReaction = normalizedContent; |
|
|
} |
|
|
} |
|
|
|
|
|
reactions = reactionMap; |
|
|
} |
|
|
|
|
|
async function toggleReaction(content: string) { |
|
|
if (!sessionManager.isLoggedIn()) { |
|
|
alert('Please log in to react'); |
|
|
return; |
|
|
} |
|
|
|
|
|
// For kind 11/1111, only allow + and - |
|
|
if ((event.kind === 11 || event.kind === 1111) && content !== '+' && content !== '-') { |
|
|
return; |
|
|
} |
|
|
|
|
|
// If clicking the same reaction, remove it |
|
|
if (userReaction === content) { |
|
|
// TODO: Implement reaction deletion (kind 5 or remove event) |
|
|
// For now, just remove from UI |
|
|
userReaction = null; |
|
|
const reaction = reactions.get(content); |
|
|
if (reaction) { |
|
|
const currentUser = sessionManager.getCurrentPubkey(); |
|
|
if (currentUser) { |
|
|
reaction.pubkeys.delete(currentUser); |
|
|
if (reaction.pubkeys.size === 0) { |
|
|
reactions.delete(content); |
|
|
} |
|
|
} |
|
|
} |
|
|
return; |
|
|
} |
|
|
|
|
|
// Publish new reaction |
|
|
try { |
|
|
const tags: string[][] = [ |
|
|
['e', event.id], |
|
|
['p', event.pubkey], |
|
|
['k', event.kind.toString()] |
|
|
]; |
|
|
|
|
|
if (sessionManager.getCurrentPubkey() && includeClientTag) { |
|
|
tags.push(['client', 'Aitherboard']); |
|
|
} |
|
|
|
|
|
const reactionEvent: Omit<NostrEvent, 'id' | 'sig'> = { |
|
|
kind: 7, |
|
|
pubkey: sessionManager.getCurrentPubkey()!, |
|
|
created_at: Math.floor(Date.now() / 1000), |
|
|
tags, |
|
|
content |
|
|
}; |
|
|
|
|
|
const config = nostrClient.getConfig(); |
|
|
await signAndPublish(reactionEvent, [...config.defaultRelays]); |
|
|
|
|
|
// Update local state |
|
|
userReaction = content; |
|
|
const currentPubkey = sessionManager.getCurrentPubkey()!; |
|
|
if (!reactions.has(content)) { |
|
|
reactions.set(content, { content, pubkeys: new Set([currentPubkey]) }); |
|
|
} else { |
|
|
reactions.get(content)!.pubkeys.add(currentPubkey); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Error publishing reaction:', error); |
|
|
alert('Error publishing reaction'); |
|
|
} |
|
|
} |
|
|
|
|
|
function getReactionDisplay(content: string): string { |
|
|
if (content === '+') { |
|
|
return event.kind === 1 ? '❤️' : '↑'; |
|
|
} |
|
|
if (content === '-') { |
|
|
return '↓'; |
|
|
} |
|
|
return content; |
|
|
} |
|
|
|
|
|
function getReactionCount(content: string): number { |
|
|
return reactions.get(content)?.pubkeys.size || 0; |
|
|
} |
|
|
|
|
|
let includeClientTag = $state(true); |
|
|
</script> |
|
|
|
|
|
<div class="reaction-buttons flex gap-2 items-center"> |
|
|
{#if event.kind === 11 || event.kind === 1111} |
|
|
<!-- Thread/Comment reactions: Only + and - --> |
|
|
<button |
|
|
onclick={() => toggleReaction('+')} |
|
|
class="reaction-btn {userReaction === '+' ? 'active' : ''}" |
|
|
title="Upvote" |
|
|
aria-label="Upvote" |
|
|
> |
|
|
↑ {getReactionCount('+')} |
|
|
</button> |
|
|
<button |
|
|
onclick={() => toggleReaction('-')} |
|
|
class="reaction-btn {userReaction === '-' ? 'active' : ''}" |
|
|
title="Downvote" |
|
|
aria-label="Downvote" |
|
|
> |
|
|
↓ {getReactionCount('-')} |
|
|
</button> |
|
|
{:else} |
|
|
<!-- Kind 1 reactions: All reactions allowed, default + --> |
|
|
<button |
|
|
onclick={() => toggleReaction('+')} |
|
|
class="reaction-btn {userReaction === '+' ? 'active' : ''}" |
|
|
title="Like" |
|
|
aria-label="Like" |
|
|
> |
|
|
❤️ {getReactionCount('+')} |
|
|
</button> |
|
|
<!-- Other reactions could go in a submenu --> |
|
|
{/if} |
|
|
</div> |
|
|
|
|
|
<style> |
|
|
.reaction-buttons { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
} |
|
|
|
|
|
.reaction-btn { |
|
|
padding: 0.25rem 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; |
|
|
font-size: 0.875rem; |
|
|
line-height: 1.5; |
|
|
display: inline-flex; |
|
|
align-items: center; |
|
|
} |
|
|
|
|
|
:global(.dark) .reaction-btn { |
|
|
background: var(--fog-dark-post, #1f2937); |
|
|
border-color: var(--fog-dark-border, #374151); |
|
|
color: var(--fog-dark-text, #f9fafb); |
|
|
} |
|
|
|
|
|
.reaction-btn:hover { |
|
|
background: var(--fog-highlight, #f3f4f6); |
|
|
border-color: var(--fog-accent, #64748b); |
|
|
} |
|
|
|
|
|
:global(.dark) .reaction-btn:hover { |
|
|
background: var(--fog-dark-highlight, #374151); |
|
|
} |
|
|
|
|
|
.reaction-btn.active { |
|
|
background: var(--fog-accent, #64748b); |
|
|
color: var(--fog-text, #475569); |
|
|
border-color: var(--fog-accent, #64748b); |
|
|
} |
|
|
|
|
|
:global(.dark) .reaction-btn.active { |
|
|
background: var(--fog-dark-accent, #64748b); |
|
|
color: var(--fog-dark-text, #cbd5e1); |
|
|
border-color: var(--fog-dark-accent, #64748b); |
|
|
} |
|
|
|
|
|
.reaction-btn:disabled { |
|
|
opacity: 0.5; |
|
|
cursor: not-allowed; |
|
|
} |
|
|
</style>
|
|
|
|