Add repost/comment to article view, redesign audio cards in feed

Article view:
- Repost button in header and footer (kind-6 repost)
- Comment button opens inline input, publishes note with naddr reference
- Footer comment scrolls to top to show input

Audio cards:
- Replace inline <audio> elements with styled play cards
- Clean filename display (decode URI, strip extension, humanize separators)
- Single click loads into persistent podcast player
- Eliminates buffering spinners from slow podcast CDNs
This commit is contained in:
Jure
2026-03-21 14:46:18 +01:00
parent 04180cf186
commit 04498aeb21
2 changed files with 134 additions and 25 deletions

View File

@@ -4,7 +4,8 @@ import { renderMarkdown } from "../../lib/markdown";
import { useUIStore } from "../../stores/ui";
import { useUserStore } from "../../stores/user";
import { useBookmarkStore } from "../../stores/bookmark";
import { fetchArticle, publishReaction } from "../../lib/nostr";
import { fetchArticle, publishReaction, publishRepost, publishNote } from "../../lib/nostr";
import { nip19 } from "@nostr-dev-kit/ndk";
import { useProfile } from "../../hooks/useProfile";
import { ZapModal } from "../zap/ZapModal";
@@ -73,6 +74,9 @@ export function ArticleView() {
const [error, setError] = useState<string | null>(null);
const [showZap, setShowZap] = useState(false);
const [reacted, setReacted] = useState(false);
const [reposted, setReposted] = useState(false);
const [showComment, setShowComment] = useState(false);
const [commentText, setCommentText] = useState("");
const { isBookmarked, addBookmark, removeBookmark } = useBookmarkStore();
const naddr = pendingArticleNaddr ?? "";
@@ -197,6 +201,28 @@ export function ArticleView() {
else addBookmark(event.id);
};
const handleRepost = async () => {
if (!event || reposted) return;
setReposted(true);
try {
await publishRepost(event);
} catch {
setReposted(false);
}
};
const handleComment = async () => {
if (!event || !commentText.trim()) return;
const dTag = event.tags.find((t) => t[0] === "d")?.[1] ?? "";
const naddrStr = nip19.naddrEncode({ identifier: dTag, pubkey: event.pubkey, kind: 30023 });
const text = `${commentText.trim()}\n\nnostr:${naddrStr}`;
try {
await publishNote(text);
setCommentText("");
setShowComment(false);
} catch { /* ignore */ }
};
return (
<div className="h-full flex flex-col">
{/* Header */}
@@ -226,6 +252,31 @@ export function ArticleView() {
zap {authorName}
</button>
)}
{event && loggedIn && (
<button
onClick={handleRepost}
disabled={reposted}
className={`text-[11px] px-3 py-1 border transition-colors ${
reposted
? "border-accent/40 text-accent"
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
}`}
>
{reposted ? "reposted" : "repost"}
</button>
)}
{event && loggedIn && (
<button
onClick={() => setShowComment(!showComment)}
className={`text-[11px] px-3 py-1 border transition-colors ${
showComment
? "border-accent/40 text-accent"
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
}`}
>
comment
</button>
)}
{naddr && (
<button
onClick={() => navigator.clipboard.writeText(`nostr:${naddr}`)}
@@ -238,6 +289,28 @@ export function ArticleView() {
</div>
</header>
{/* Comment box */}
{showComment && event && (
<div className="border-b border-border px-4 py-3 flex gap-2 shrink-0">
<input
type="text"
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleComment()}
placeholder="Write a comment about this article..."
className="flex-1 bg-bg-raised border border-border rounded-sm px-3 py-1.5 text-[12px] text-text placeholder:text-text-dim focus:outline-none focus:border-accent"
autoFocus
/>
<button
onClick={handleComment}
disabled={!commentText.trim()}
className="px-3 py-1.5 bg-accent/10 text-accent text-[12px] rounded-sm hover:bg-accent/20 transition-colors disabled:opacity-40"
>
post
</button>
</div>
)}
{/* Body */}
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{/* Reading progress bar */}
@@ -346,6 +419,27 @@ export function ArticleView() {
{bookmarked ? "▪ saved" : "▫ save"}
</button>
)}
{loggedIn && (
<button
onClick={handleRepost}
disabled={reposted}
className={`text-[11px] px-3 py-1.5 border transition-colors ${
reposted
? "border-accent/40 text-accent"
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
}`}
>
{reposted ? "reposted" : "repost"}
</button>
)}
{loggedIn && (
<button
onClick={() => { setShowComment(true); scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }}
className="text-[11px] px-3 py-1.5 border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors"
>
comment
</button>
)}
{loggedIn && (
<button
onClick={() => setShowZap(true)}

View File

@@ -20,38 +20,53 @@ export function VideoBlock({ sources }: { sources: string[] }) {
);
}
function cleanAudioName(url: string): string {
const raw = url.split("/").pop()?.split("?")[0] ?? url;
// Remove file extension
const name = raw.replace(/\.(mp3|m4a|ogg|opus|wav|flac|aac)$/i, "");
// Decode URI components and replace common separators with spaces
try {
return decodeURIComponent(name).replace(/[-_]+/g, " ");
} catch {
return name.replace(/[-_]+/g, " ");
}
}
export function AudioBlock({ sources }: { sources: string[] }) {
const play = usePodcastStore((s) => s.play);
if (sources.length === 0) return null;
return (
<div className="mt-2 flex flex-col gap-2">
{sources.map((src, i) => {
const filename = src.split("/").pop()?.split("?")[0] ?? src;
const name = cleanAudioName(src);
return (
<div key={i} className="rounded-sm bg-bg-raised border border-border p-2">
<div className="flex items-center justify-between mb-1">
<div className="text-[11px] text-text-muted truncate">{filename}</div>
<button
onClick={(e) => {
e.stopPropagation();
play({
guid: `audio:${src}`,
title: filename,
enclosureUrl: src,
pubDate: 0,
duration: 0,
description: "",
showTitle: "From note",
showArtworkUrl: "",
});
}}
className="text-[10px] text-accent hover:text-accent-hover transition-colors shrink-0 ml-2"
>
play in wrystr
</button>
<button
key={i}
onClick={(e) => {
e.stopPropagation();
play({
guid: `audio:${src}`,
title: name,
enclosureUrl: src,
pubDate: 0,
duration: 0,
description: "",
showTitle: "",
showArtworkUrl: "",
});
}}
className="rounded-sm bg-bg-raised border border-border p-3 flex items-center gap-3 hover:bg-bg-hover transition-colors text-left w-full"
>
<div className="w-8 h-8 rounded-full bg-accent/10 flex items-center justify-center shrink-0">
<svg width="10" height="12" viewBox="0 0 10 12" fill="currentColor" className="text-accent ml-0.5">
<polygon points="0,0 10,6 0,12" />
</svg>
</div>
<audio controls preload="metadata" className="w-full h-8" src={src} />
</div>
<div className="min-w-0 flex-1">
<div className="text-[12px] text-text truncate">{name}</div>
<div className="text-[10px] text-text-dim">audio · play in wrystr</div>
</div>
</button>
);
})}
</div>