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
+135 -3
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)}
/>
)}
</>
);
}