mirror of
https://github.com/hoornet/vega.git
synced 2026-05-12 17:58:36 -07:00
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:
@@ -6,9 +6,10 @@ import { useUserStore } from "../../stores/user";
|
|||||||
import { useMuteStore } from "../../stores/mute";
|
import { useMuteStore } from "../../stores/mute";
|
||||||
import { useUIStore } from "../../stores/ui";
|
import { useUIStore } from "../../stores/ui";
|
||||||
import { timeAgo, shortenPubkey } from "../../lib/utils";
|
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 { NoteContent } from "./NoteContent";
|
||||||
import { ZapModal } from "../zap/ZapModal";
|
import { ZapModal } from "../zap/ZapModal";
|
||||||
|
import { QuoteModal } from "./QuoteModal";
|
||||||
|
|
||||||
interface NoteCardProps {
|
interface NoteCardProps {
|
||||||
event: NDKEvent;
|
event: NDKEvent;
|
||||||
@@ -40,6 +41,9 @@ export function NoteCard({ event }: NoteCardProps) {
|
|||||||
const [replySent, setReplySent] = useState(false);
|
const [replySent, setReplySent] = useState(false);
|
||||||
const replyRef = useRef<HTMLTextAreaElement>(null);
|
const replyRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [showZap, setShowZap] = useState(false);
|
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 [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
const handleLike = async () => {
|
const handleLike = async () => {
|
||||||
@@ -83,6 +87,17 @@ export function NoteCard({ event }: NoteCardProps) {
|
|||||||
if (e.key === "Escape") setShowReply(false);
|
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 (
|
return (
|
||||||
<article className="border-b border-border px-4 py-3 hover:bg-bg-hover transition-colors duration-100 group/card">
|
<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">
|
<div className="flex gap-3">
|
||||||
@@ -169,6 +184,21 @@ export function NoteCard({ event }: NoteCardProps) {
|
|||||||
>
|
>
|
||||||
{liked ? "♥" : "♡"}{reactionCount !== null && reactionCount > 0 ? ` ${reactionCount}` : liked ? " liked" : " like"}
|
{liked ? "♥" : "♡"}{reactionCount !== null && reactionCount > 0 ? ` ${reactionCount}` : liked ? " liked" : " like"}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setShowZap(true)}
|
onClick={() => setShowZap(true)}
|
||||||
className="text-[11px] text-text-dim hover:text-zap transition-colors"
|
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 */}
|
{/* Inline reply box */}
|
||||||
{showReply && (
|
{showReply && (
|
||||||
<div className="mt-2 border-l-2 border-border pl-3">
|
<div className="mt-2 border-l-2 border-border pl-3">
|
||||||
|
|||||||
102
src/components/feed/QuoteModal.tsx
Normal file
102
src/components/feed/QuoteModal.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||||
|
import { publishQuote } from "../../lib/nostr";
|
||||||
|
|
||||||
|
interface QuoteModalProps {
|
||||||
|
event: NDKEvent;
|
||||||
|
authorName: string;
|
||||||
|
authorAvatar?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onPublished?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuoteModal({ event, authorName, authorAvatar, onClose, onPublished }: QuoteModalProps) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const canPublish = text.trim().length > 0 && !publishing;
|
||||||
|
|
||||||
|
const handlePublish = async () => {
|
||||||
|
if (!canPublish) return;
|
||||||
|
setPublishing(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await publishQuote(text.trim(), event);
|
||||||
|
onPublished?.();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Failed to publish: ${err}`);
|
||||||
|
setPublishing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handlePublish();
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = event.content.slice(0, 140) + (event.content.length > 140 ? "…" : "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
>
|
||||||
|
<div className="bg-bg border border-border w-full max-w-md mx-4 shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<h2 className="text-text text-sm font-medium">Quote note</h2>
|
||||||
|
<button onClick={onClose} className="text-text-dim hover:text-text text-lg leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compose */}
|
||||||
|
<div className="p-4">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Add your comment…"
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-transparent text-text text-[13px] placeholder:text-text-dim resize-none focus:outline-none mb-3"
|
||||||
|
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Quoted note preview */}
|
||||||
|
<div className="border border-border px-3 py-2.5 bg-bg-raised rounded-sm">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
{authorAvatar ? (
|
||||||
|
<img src={authorAvatar} alt="" className="w-4 h-4 rounded-sm object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-4 h-4 rounded-sm bg-accent/20 flex items-center justify-center text-accent text-[8px]">
|
||||||
|
{authorName.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-text-muted text-[11px] font-medium">{authorName}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-text-dim text-[12px] leading-relaxed whitespace-pre-wrap break-words">{preview}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-danger text-[11px] mt-2">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
<span className="text-text-dim text-[10px]">Ctrl+Enter to post</span>
|
||||||
|
<button
|
||||||
|
onClick={handlePublish}
|
||||||
|
disabled={!canPublish}
|
||||||
|
className="px-4 py-1.5 text-[11px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{publishing ? "posting…" : "quote & post"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import NDK, { NDKEvent, NDKFilter, NDKKind, NDKRelay, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk";
|
import NDK, { NDKEvent, NDKFilter, NDKKind, NDKRelay, NDKSubscriptionCacheUsage, nip19 } from "@nostr-dev-kit/ndk";
|
||||||
|
|
||||||
const RELAY_STORAGE_KEY = "wrystr_relays";
|
const RELAY_STORAGE_KEY = "wrystr_relays";
|
||||||
|
|
||||||
@@ -146,6 +146,37 @@ export async function publishArticle(opts: {
|
|||||||
await event.publish();
|
await event.publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function publishRepost(event: NDKEvent): Promise<void> {
|
||||||
|
const instance = getNDK();
|
||||||
|
if (!instance.signer) throw new Error("Not logged in");
|
||||||
|
|
||||||
|
const repost = new NDKEvent(instance);
|
||||||
|
repost.kind = NDKKind.Repost; // kind 6
|
||||||
|
repost.content = JSON.stringify(event.rawEvent());
|
||||||
|
repost.tags = [
|
||||||
|
["e", event.id!, "", "mention"],
|
||||||
|
["p", event.pubkey],
|
||||||
|
];
|
||||||
|
await repost.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishQuote(content: string, quotedEvent: NDKEvent): Promise<void> {
|
||||||
|
const instance = getNDK();
|
||||||
|
if (!instance.signer) throw new Error("Not logged in");
|
||||||
|
|
||||||
|
const nevent = nip19.neventEncode({ id: quotedEvent.id!, author: quotedEvent.pubkey });
|
||||||
|
const fullContent = content.trim() + "\n\nnostr:" + nevent;
|
||||||
|
|
||||||
|
const note = new NDKEvent(instance);
|
||||||
|
note.kind = NDKKind.Text;
|
||||||
|
note.content = fullContent;
|
||||||
|
note.tags = [
|
||||||
|
["q", quotedEvent.id!, ""],
|
||||||
|
["p", quotedEvent.pubkey],
|
||||||
|
];
|
||||||
|
await note.publish();
|
||||||
|
}
|
||||||
|
|
||||||
export async function publishReaction(eventId: string, eventPubkey: string, reaction = "+"): Promise<void> {
|
export async function publishReaction(eventId: string, eventPubkey: string, reaction = "+"): Promise<void> {
|
||||||
const instance = getNDK();
|
const instance = getNDK();
|
||||||
if (!instance.signer) throw new Error("Not logged in");
|
if (!instance.signer) throw new Error("Not logged in");
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishReply, publishContactList, fetchReactionCount, fetchUserNotes, fetchProfile, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers } from "./client";
|
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchReactionCount, fetchUserNotes, fetchProfile, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers } from "./client";
|
||||||
|
|||||||
Reference in New Issue
Block a user