Bump to v0.8.3 — trending feed, NIP-46 remote signer, media feed, profile media gallery

This commit is contained in:
Jure
2026-03-20 12:45:58 +01:00
parent 57630227e1
commit 0bcbba6e8f
21 changed files with 538 additions and 162 deletions

View File

@@ -6,6 +6,7 @@ import { useUIStore } from "../../stores/ui";
import { fetchFollowFeed, getNDK } from "../../lib/nostr";
import { detectScript, getEventLanguageTag, FILTER_SCRIPTS } from "../../lib/language";
import { NoteCard } from "./NoteCard";
import { ArticleCard } from "../article/ArticleCard";
import { ComposeBox } from "./ComposeBox";
import { SkeletonNoteList } from "../shared/Skeleton";
import { NDKEvent } from "@nostr-dev-kit/ndk";
@@ -168,25 +169,33 @@ export function Feed() {
{!isLoading && filteredNotes.length === 0 && (
<div className="px-4 py-12 text-center space-y-2">
<p className="text-text-dim text-[13px]">
{isFollowing && follows.length === 0
? "You're not following anyone yet."
: feedLanguageFilter
? `No ${feedLanguageFilter} notes found.`
: "No notes to show."}
{isTrending
? "No trending notes right now."
: isFollowing && follows.length === 0
? "You're not following anyone yet."
: feedLanguageFilter
? `No ${feedLanguageFilter} notes found.`
: "No notes to show."}
</p>
<p className="text-text-dim text-[11px] opacity-60">
{isFollowing && follows.length === 0
? "Use search to find people to follow."
: feedLanguageFilter
? "Try clearing the script filter or refreshing."
: "Try refreshing or switching tabs."}
{isTrending
? "Check back in a bit."
: isFollowing && follows.length === 0
? "Use search to find people to follow."
: feedLanguageFilter
? "Try clearing the script filter or refreshing."
: "Try refreshing or switching tabs."}
</p>
</div>
)}
{filteredNotes.map((event, index) => (
<NoteCard key={event.id} event={event} focused={focusedNoteIndex === index} />
))}
{filteredNotes.map((event, index) =>
event.kind === 30023 ? (
<ArticleCard key={event.id} event={event} />
) : (
<NoteCard key={event.id} event={event} focused={focusedNoteIndex === index} />
)
)}
</div>
</div>
);

View File

@@ -0,0 +1,83 @@
import { useEffect, useState } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { fetchGlobalFeed } from "../../lib/nostr";
import { parseContent, ContentSegment } from "../../lib/parsing";
import { NoteCard } from "../feed/NoteCard";
import { SkeletonNoteList } from "../shared/Skeleton";
type MediaTab = "all" | "videos" | "images" | "audio";
const MEDIA_TYPES: Record<MediaTab, ContentSegment["type"][]> = {
all: ["image", "video", "audio", "youtube", "vimeo", "spotify", "tidal"],
videos: ["video", "youtube", "vimeo"],
images: ["image"],
audio: ["audio", "spotify", "tidal"],
};
function hasMediaType(content: string, types: ContentSegment["type"][]): boolean {
const segments = parseContent(content);
return segments.some((s) => types.includes(s.type));
}
export function MediaFeed() {
const [allNotes, setAllNotes] = useState<NDKEvent[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<MediaTab>("all");
useEffect(() => {
setLoading(true);
fetchGlobalFeed(300)
.then((notes) => {
const mediaNotes = notes.filter((n) => hasMediaType(n.content, MEDIA_TYPES.all));
setAllNotes(mediaNotes);
})
.catch(() => setAllNotes([]))
.finally(() => setLoading(false));
}, []);
const filtered = tab === "all"
? allNotes
: allNotes.filter((n) => hasMediaType(n.content, MEDIA_TYPES[tab]));
return (
<div className="h-full flex flex-col">
<header className="border-b border-border px-4 py-2.5 flex items-center justify-between shrink-0">
<div className="flex items-center gap-1">
<h1 className="text-text text-sm font-medium mr-3">Media</h1>
{(["all", "videos", "images", "audio"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-3 py-1 text-[12px] transition-colors ${
tab === t
? "text-text border-b-2 border-accent"
: "text-text-muted hover:text-text"
}`}
>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
</header>
<div className="flex-1 overflow-y-auto">
{loading && <SkeletonNoteList count={4} />}
{!loading && filtered.length === 0 && (
<div className="px-4 py-12 text-center space-y-2">
<p className="text-text-dim text-[13px]">
No {tab === "all" ? "media" : tab} found.
</p>
<p className="text-text-dim text-[11px] opacity-60">
Try switching tabs or check back later.
</p>
</div>
)}
{filtered.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</div>
</div>
);
}

View File

@@ -187,8 +187,8 @@ function BackupStep({ signer, onComplete }: { signer: NDKPrivateKeySigner; onCom
// ─── Step: Login with existing key ───────────────────────────────────────────
function LoginStep({ onBack, onComplete }: { onBack: () => void; onComplete: () => void }) {
const { loginWithNsec, loginWithPubkey, loginError, loggedIn } = useUserStore();
const [mode, setMode] = useState<"nsec" | "npub">("nsec");
const { loginWithNsec, loginWithPubkey, loginWithRemoteSigner, loginError, loggedIn } = useUserStore();
const [mode, setMode] = useState<"nsec" | "npub" | "bunker">("nsec");
const [value, setValue] = useState("");
const [loading, setLoading] = useState(false);
@@ -201,6 +201,8 @@ function LoginStep({ onBack, onComplete }: { onBack: () => void; onComplete: ()
setLoading(true);
if (mode === "nsec") {
await loginWithNsec(value.trim());
} else if (mode === "bunker") {
await loginWithRemoteSigner(value.trim());
} else {
await loginWithPubkey(value.trim());
}
@@ -211,12 +213,15 @@ function LoginStep({ onBack, onComplete }: { onBack: () => void; onComplete: ()
if (e.key === "Enter") handleLogin();
};
const tabLabel = (m: "nsec" | "npub" | "bunker") =>
m === "nsec" ? "Secret key" : m === "npub" ? "Public key" : "Remote signer";
return (
<Shell>
<Heading>Log in with your key.</Heading>
<div className="flex border border-border mb-4">
{(["nsec", "npub"] as const).map((m) => (
{(["nsec", "npub", "bunker"] as const).map((m) => (
<button
key={m}
onClick={() => { setMode(m); setValue(""); }}
@@ -224,7 +229,7 @@ function LoginStep({ onBack, onComplete }: { onBack: () => void; onComplete: ()
mode === m ? "bg-accent/10 text-accent" : "text-text-dim hover:text-text"
}`}
>
{m === "nsec" ? "Secret key (nsec)" : "Public key only (read-only)"}
{tabLabel(m)}
</button>
))}
</div>
@@ -233,7 +238,7 @@ function LoginStep({ onBack, onComplete }: { onBack: () => void; onComplete: ()
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={mode === "nsec" ? "nsec1…" : "npub1…"}
placeholder={mode === "nsec" ? "nsec1…" : mode === "npub" ? "npub1…" : "bunker://…"}
autoFocus
className="w-full bg-bg border border-border px-3 py-2 text-text text-[12px] font-mono focus:outline-none focus:border-accent/50 placeholder:text-text-dim mb-2"
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
@@ -242,6 +247,9 @@ function LoginStep({ onBack, onComplete }: { onBack: () => void; onComplete: ()
{mode === "npub" && (
<p className="text-text-dim text-[11px] mb-4">Read-only mode you can browse but not post, react, or zap.</p>
)}
{mode === "bunker" && (
<p className="text-text-dim text-[11px] mb-4">Connect to nsecBunker, Amber, or similar. Paste your bunker:// URI.</p>
)}
{loginError && <p className="text-danger text-[11px] mb-3">{loginError}</p>}
@@ -251,7 +259,7 @@ function LoginStep({ onBack, onComplete }: { onBack: () => void; onComplete: ()
disabled={!value.trim() || loading}
className="w-full py-2.5 text-[13px] font-medium bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
{loading ? "Logging in…" : "Log in"}
{loading ? (mode === "bunker" ? "Connecting…" : "Logging in…") : "Log in"}
</button>
<button
onClick={onBack}

View File

@@ -5,6 +5,7 @@ import { useUserStore } from "../../stores/user";
import { useMuteStore } from "../../stores/mute";
import { useProfile, invalidateProfileCache } from "../../hooks/useProfile";
import { fetchUserNotesNIP65, fetchAuthorArticles, publishProfile, getNDK } from "../../lib/nostr";
import { parseContent } from "../../lib/parsing";
import { shortenPubkey } from "../../lib/utils";
import { uploadImage } from "../../lib/upload";
import { NoteCard } from "../feed/NoteCard";
@@ -231,7 +232,7 @@ export function ProfileView() {
const [editing, setEditing] = useState(false);
const [followPending, setFollowPending] = useState(false);
const [showZap, setShowZap] = useState(false);
const [profileTab, setProfileTab] = useState<"notes" | "articles">("notes");
const [profileTab, setProfileTab] = useState<"notes" | "articles" | "media">("notes");
const [bannerLightbox, setBannerLightbox] = useState(false);
const [bannerLoaded, setBannerLoaded] = useState(false);
@@ -422,9 +423,9 @@ export function ProfileView() {
/>
)}
{/* Notes / Articles tabs */}
{/* Notes / Articles / Media tabs */}
<div className="border-b border-border flex shrink-0">
{(["notes", "articles"] as const).map((t) => (
{(["notes", "articles", "media"] as const).map((t) => (
<button
key={t}
onClick={() => setProfileTab(t)}
@@ -458,7 +459,138 @@ export function ProfileView() {
))}
</>
)}
{profileTab === "media" && (
<ProfileMediaGallery notes={notes} loading={loading} />
)}
</div>
</div>
);
}
// ── Media gallery sub-component ──────────────────────────────────────────────
const MEDIA_SEGMENT_TYPES = new Set(["image", "video", "audio", "youtube", "vimeo"]);
interface MediaItem {
type: "image" | "video" | "audio";
url: string;
thumbnailId?: string;
noteId: string;
}
function extractMediaItems(notes: NDKEvent[]): MediaItem[] {
const items: MediaItem[] = [];
const seen = new Set<string>();
for (const note of notes) {
const segments = parseContent(note.content);
for (const seg of segments) {
if (!MEDIA_SEGMENT_TYPES.has(seg.type)) continue;
if (seen.has(seg.value)) continue;
seen.add(seg.value);
if (seg.type === "image") {
items.push({ type: "image", url: seg.value, noteId: note.id! });
} else if (seg.type === "video" || seg.type === "youtube" || seg.type === "vimeo") {
items.push({ type: "video", url: seg.value, thumbnailId: seg.mediaId, noteId: note.id! });
} else if (seg.type === "audio") {
items.push({ type: "audio", url: seg.value, noteId: note.id! });
}
}
}
return items;
}
function ProfileMediaGallery({ notes, loading }: { notes: NDKEvent[]; loading: boolean }) {
const { openThread } = useUIStore();
const [lightboxIdx, setLightboxIdx] = useState<number | null>(null);
if (loading) {
return <div className="px-4 py-8 text-text-dim text-[12px] text-center">Loading media</div>;
}
const items = extractMediaItems(notes);
const imageUrls = items.filter((i) => i.type === "image").map((i) => i.url);
if (items.length === 0) {
return <div className="px-4 py-8 text-text-dim text-[12px] text-center">No media found.</div>;
}
const openNote = (noteId: string) => {
const note = notes.find((n) => n.id === noteId);
if (note) openThread(note, "profile");
};
let imageIndex = 0;
return (
<>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-1 p-2">
{items.map((item, idx) => {
if (item.type === "image") {
const currentImageIdx = imageIndex++;
return (
<div
key={idx}
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setLightboxIdx(currentImageIdx)}
>
<img
src={item.url}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
</div>
);
}
if (item.type === "video") {
return (
<div
key={idx}
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity relative flex items-center justify-center"
onClick={() => openNote(item.noteId)}
>
{item.thumbnailId ? (
<img
src={`https://img.youtube.com/vi/${item.thumbnailId}/mqdefault.jpg`}
alt=""
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full bg-bg-raised" />
)}
<div className="absolute inset-0 flex items-center justify-center">
<span className="w-10 h-10 rounded-full bg-black/60 flex items-center justify-center text-white text-lg"></span>
</div>
</div>
);
}
// audio
return (
<div
key={idx}
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity flex items-center justify-center"
onClick={() => openNote(item.noteId)}
>
<div className="text-center">
<span className="text-3xl text-text-dim"></span>
<p className="text-text-dim text-[10px] mt-1 px-2 truncate">{item.url.split("/").pop()}</p>
</div>
</div>
);
})}
</div>
{lightboxIdx !== null && (
<ImageLightbox
images={imageUrls}
index={lightboxIdx}
onClose={() => setLightboxIdx(null)}
onNavigate={(i) => setLightboxIdx(i)}
/>
)}
</>
);
}

View File

@@ -78,19 +78,24 @@ function NewAccountTab({ onClose }: { onClose: () => void }) {
}
export function LoginModal({ onClose }: LoginModalProps) {
const [tab, setTab] = useState<"nsec" | "pubkey" | "new">("nsec");
const [tab, setTab] = useState<"nsec" | "pubkey" | "bunker" | "new">("nsec");
const [input, setInput] = useState("");
const { loginWithNsec, loginWithPubkey, loginError } = useUserStore();
const [loading, setLoading] = useState(false);
const { loginWithNsec, loginWithPubkey, loginWithRemoteSigner, loginError } = useUserStore();
const handleLogin = async () => {
if (!input.trim()) return;
if (!input.trim() || loading) return;
setLoading(true);
if (tab === "nsec") {
await loginWithNsec(input.trim());
} else if (tab === "pubkey") {
await loginWithPubkey(input.trim());
} else if (tab === "bunker") {
await loginWithRemoteSigner(input.trim());
}
setLoading(false);
// Close if no error
if (!useUserStore.getState().loginError) {
onClose();
@@ -124,36 +129,19 @@ export function LoginModal({ onClose }: LoginModalProps) {
{/* Tabs */}
<div className="flex border-b border-border">
<button
onClick={() => setTab("nsec")}
className={`flex-1 px-4 py-2 text-[12px] transition-colors ${
tab === "nsec"
? "text-accent border-b-2 border-accent"
: "text-text-muted hover:text-text"
}`}
>
Private key
</button>
<button
onClick={() => setTab("pubkey")}
className={`flex-1 px-4 py-2 text-[12px] transition-colors ${
tab === "pubkey"
? "text-accent border-b-2 border-accent"
: "text-text-muted hover:text-text"
}`}
>
Read-only
</button>
<button
onClick={() => setTab("new")}
className={`flex-1 px-4 py-2 text-[12px] transition-colors ${
tab === "new"
? "text-accent border-b-2 border-accent"
: "text-text-muted hover:text-text"
}`}
>
New account
</button>
{(["nsec", "pubkey", "bunker", "new"] as const).map((t) => (
<button
key={t}
onClick={() => { setTab(t); setInput(""); }}
className={`flex-1 px-3 py-2 text-[11px] transition-colors ${
tab === t
? "text-accent border-b-2 border-accent"
: "text-text-muted hover:text-text"
}`}
>
{t === "nsec" ? "Private key" : t === "pubkey" ? "Read-only" : t === "bunker" ? "Remote signer" : "New account"}
</button>
))}
</div>
{/* Content */}
@@ -165,14 +153,16 @@ export function LoginModal({ onClose }: LoginModalProps) {
<label className="block text-text-muted text-[11px] mb-1.5">
{tab === "nsec"
? "Paste your nsec or hex private key"
: "Paste your npub or hex public key"}
: tab === "pubkey"
? "Paste your npub or hex public key"
: "Paste your bunker:// URI"}
</label>
<input
type={tab === "nsec" ? "password" : "text"}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={tab === "nsec" ? "nsec1…" : "npub1…"}
placeholder={tab === "nsec" ? "nsec1…" : tab === "pubkey" ? "npub1…" : "bunker://…"}
autoFocus
className="w-full bg-bg border border-border px-3 py-2 text-text text-[13px] font-mono placeholder:text-text-dim focus:outline-none focus:border-accent/50"
/>
@@ -189,16 +179,24 @@ export function LoginModal({ onClose }: LoginModalProps) {
</p>
)}
{tab === "bunker" && (
<p className="text-text-dim text-[10px] mt-1.5">
Connect to nsecBunker, Amber, or similar. Your keys never leave the signer.
</p>
)}
{loginError && (
<p className="text-danger text-[11px] mt-2">{loginError}</p>
)}
<button
onClick={handleLogin}
disabled={!input.trim()}
disabled={!input.trim() || loading}
className="w-full mt-3 px-4 py-2 text-[12px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
{tab === "nsec" ? "Login" : "View as read-only"}
{loading
? (tab === "bunker" ? "Connecting…" : "Logging in…")
: tab === "nsec" ? "Login" : tab === "pubkey" ? "View as read-only" : "Connect"}
</button>
</>
)}

View File

@@ -98,6 +98,9 @@ export function AccountSwitcher() {
>
<Avatar account={a} />
<span className="text-text-muted text-[11px] truncate flex-1">{displayName(a)}</span>
{a.loginType === "remote-signer" && (
<span className="text-[10px] text-text-dim" title="Remote signer (NIP-46)">NIP-46</span>
)}
<button
onClick={(e) => handleRemove(e, a.pubkey)}
className="text-text-dim hover:text-danger text-[11px] opacity-0 group-hover:opacity-100 transition-opacity"

View File

@@ -11,6 +11,7 @@ import pkg from "../../../package.json";
const NAV_ITEMS = [
{ id: "feed" as const, label: "feed", icon: "◈" },
{ id: "articles" as const, label: "articles", icon: "☰" },
{ id: "media" as const, label: "media", icon: "▶" },
{ id: "search" as const, label: "search", icon: "⌕" },
{ id: "bookmarks" as const, label: "bookmarks", icon: "▪" },
{ id: "dm" as const, label: "messages", icon: "✉" },