Add Quote / Repost (NIP-18, roadmap #6)

- publishRepost: kind 6 event with stringified original event as content
  and ["e", id, "", "mention"] + ["p", pubkey] tags
- publishQuote: kind 1 note with user's text + appended nostr:nevent1...
  reference and ["q", id] + ["p", pubkey] tags
- QuoteModal: compose modal with live quoted-note preview (avatar, name,
  truncated content); Ctrl+Enter to post, Escape to close
- NoteCard: "repost" (one-click, shows "reposted ✓") and "quote" (opens
  QuoteModal) added to the actions row alongside reply/like/zap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jure
2026-03-10 18:28:59 +01:00
parent acf171bb74
commit 0f998eac92
4 changed files with 175 additions and 3 deletions

View File

@@ -6,9 +6,10 @@ import { useUserStore } from "../../stores/user";
import { useMuteStore } from "../../stores/mute";
import { useUIStore } from "../../stores/ui";
import { timeAgo, shortenPubkey } from "../../lib/utils";
import { publishReaction, publishReply, getNDK } from "../../lib/nostr";
import { publishReaction, publishReply, publishRepost, getNDK } from "../../lib/nostr";
import { NoteContent } from "./NoteContent";
import { ZapModal } from "../zap/ZapModal";
import { QuoteModal } from "./QuoteModal";
interface NoteCardProps {
event: NDKEvent;
@@ -40,6 +41,9 @@ export function NoteCard({ event }: NoteCardProps) {
const [replySent, setReplySent] = useState(false);
const replyRef = useRef<HTMLTextAreaElement>(null);
const [showZap, setShowZap] = useState(false);
const [showQuote, setShowQuote] = useState(false);
const [reposting, setReposting] = useState(false);
const [reposted, setReposted] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const handleLike = async () => {
@@ -83,6 +87,17 @@ export function NoteCard({ event }: NoteCardProps) {
if (e.key === "Escape") setShowReply(false);
};
const handleRepost = async () => {
if (reposting || reposted) return;
setReposting(true);
try {
await publishRepost(event);
setReposted(true);
} finally {
setReposting(false);
}
};
return (
<article className="border-b border-border px-4 py-3 hover:bg-bg-hover transition-colors duration-100 group/card">
<div className="flex gap-3">
@@ -169,6 +184,21 @@ export function NoteCard({ event }: NoteCardProps) {
>
{liked ? "♥" : "♡"}{reactionCount !== null && reactionCount > 0 ? ` ${reactionCount}` : liked ? " liked" : " like"}
</button>
<button
onClick={handleRepost}
disabled={reposting || reposted}
className={`text-[11px] transition-colors disabled:cursor-default ${
reposted ? "text-accent" : "text-text-dim hover:text-accent"
}`}
>
{reposted ? "reposted ✓" : reposting ? "…" : "repost"}
</button>
<button
onClick={() => setShowQuote(true)}
className="text-[11px] text-text-dim hover:text-text transition-colors"
>
quote
</button>
<button
onClick={() => setShowZap(true)}
className="text-[11px] text-text-dim hover:text-zap transition-colors"
@@ -186,6 +216,15 @@ export function NoteCard({ event }: NoteCardProps) {
/>
)}
{showQuote && (
<QuoteModal
event={event}
authorName={name}
authorAvatar={avatar}
onClose={() => setShowQuote(false)}
/>
)}
{/* Inline reply box */}
{showReply && (
<div className="mt-2 border-l-2 border-border pl-3">