mirror of
https://github.com/hoornet/vega.git
synced 2026-07-29 03:08:10 -07:00
Fix feed OOM: lazy image loading, inView gating, WebKit memory tuning
- NoteContent: remove ImageGrid from inline mode — images now only load when note is inView (via mediaOnly), stopping runaway scrolling leak - NoteCard: content-visibility:auto to skip layout/paint for off-screen cards; inView-gated media, NoteActions, NIP-05 verification - useInView: new IntersectionObserver hook with 300px rootMargin - useProfile: MAX_PROFILE_CONCURRENT=8 throttle with fetch queue - useReplyCount/useZapCount/useReactions: enabled param, throttled queues - feed.ts: MAX_FEED_SIZE 200→30, live sub disabled (pendingNotes pattern), 250ms batch debounce on live events - core.ts: MAX_CONCURRENT_FETCHES=25 global NDK cap, fetchWithTimeout uses subscribe+stop instead of fetchEvents (no zombie subscriptions) - lib.rs: HardwareAccelerationPolicy::Never + CacheModel::DocumentViewer - main.rs: WEBKIT_DISABLE_COMPOSITING_MODE=1 for Linux - relay/db.rs: TTL eviction + 5000 event cap - feedDiagnostics.ts: file-flushing diag log survives crashes
This commit is contained in:
@@ -4,7 +4,7 @@ import { useUserStore } from "../../stores/user";
|
||||
import { useMuteStore } from "../../stores/mute";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { useWoTStore } from "../../stores/wot";
|
||||
import { fetchFollowFeed, getNDK, ensureConnected, batchFetchProfileAges } from "../../lib/nostr";
|
||||
import { fetchFollowFeed, getNDK, ensureConnected } from "../../lib/nostr";
|
||||
import { diagWrapFetch, logDiag } from "../../lib/feedDiagnostics";
|
||||
import { detectScript, getEventLanguageTag, FILTER_SCRIPTS } from "../../lib/language";
|
||||
import { NoteCard } from "./NoteCard";
|
||||
@@ -26,6 +26,8 @@ function timeAgo(ts: number): string {
|
||||
|
||||
export function Feed() {
|
||||
const notes = useFeedStore((s) => s.notes);
|
||||
const pendingNotes = useFeedStore((s) => s.pendingNotes);
|
||||
const flushPendingNotes = useFeedStore((s) => s.flushPendingNotes);
|
||||
const loading = useFeedStore((s) => s.loading);
|
||||
const error = useFeedStore((s) => s.error);
|
||||
const connect = useFeedStore((s) => s.connect);
|
||||
@@ -63,10 +65,7 @@ export function Feed() {
|
||||
useEffect(() => {
|
||||
// Show cached notes immediately, then fetch fresh ones once connected
|
||||
loadCachedFeed();
|
||||
connect().then(() => loadFeed().then(() => {
|
||||
const pubkeys = [...new Set(useFeedStore.getState().notes.map((e) => e.pubkey))];
|
||||
batchFetchProfileAges(pubkeys);
|
||||
}));
|
||||
connect().then(() => loadFeed());
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -265,6 +264,16 @@ export function Feed() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New notes banner — only shown on global tab */}
|
||||
{tab === "global" && pendingNotes.length > 0 && (
|
||||
<button
|
||||
onClick={flushPendingNotes}
|
||||
className="w-full py-2 text-[12px] text-accent border-b border-accent/20 bg-accent/5 hover:bg-accent/10 transition-colors"
|
||||
>
|
||||
{pendingNotes.length} new {pendingNotes.length === 1 ? "note" : "notes"} — click to load
|
||||
</button>
|
||||
)}
|
||||
|
||||
{filteredNotes.map((event, index) =>
|
||||
event.kind === 30023 ? (
|
||||
<ArticleCard key={event.id} event={event} />
|
||||
|
||||
@@ -17,9 +17,10 @@ interface NoteActionsProps {
|
||||
event: NDKEvent;
|
||||
onReplyToggle: () => void;
|
||||
showReply: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProps) {
|
||||
export function NoteActions({ event, onReplyToggle, showReply, enabled = true }: NoteActionsProps) {
|
||||
const profile = useProfile(event.pubkey);
|
||||
const name = profileName(profile, event.pubkey.slice(0, 8) + "…");
|
||||
const avatar = typeof profile?.picture === "string" ? profile.picture : undefined;
|
||||
@@ -27,12 +28,12 @@ export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProp
|
||||
const { bookmarkedIds, addBookmark, removeBookmark } = useBookmarkStore();
|
||||
const isBookmarked = bookmarkedIds.includes(event.id!);
|
||||
|
||||
const [reactionsData, addReaction] = useReactions(event.id);
|
||||
const [reactionsData, addReaction] = useReactions(event.id, enabled);
|
||||
const [reacting, setReacting] = useState(false);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [replyCount] = useReplyCount(event.id);
|
||||
const [replyCount] = useReplyCount(event.id, enabled);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const zapData = useZapCount(event.id);
|
||||
const zapData = useZapCount(event.id, enabled);
|
||||
const [showZap, setShowZap] = useState(false);
|
||||
const [showQuote, setShowQuote] = useState(false);
|
||||
const [reposting, setReposting] = useState(false);
|
||||
@@ -227,10 +228,10 @@ export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProp
|
||||
);
|
||||
}
|
||||
|
||||
export function LoggedOutStats({ event }: { event: NDKEvent }) {
|
||||
const [reactionsData] = useReactions(event.id);
|
||||
const [replyCount] = useReplyCount(event.id);
|
||||
const zapData = useZapCount(event.id);
|
||||
export function LoggedOutStats({ event, enabled = true }: { event: NDKEvent; enabled?: boolean }) {
|
||||
const [reactionsData] = useReactions(event.id, enabled);
|
||||
const [replyCount] = useReplyCount(event.id, enabled);
|
||||
const zapData = useZapCount(event.id, enabled);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleShare = async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, memo } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { useNip05Verified } from "../../hooks/useNip05Verified";
|
||||
import { useInView } from "../../hooks/useInView";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useMuteStore } from "../../stores/mute";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
@@ -27,12 +28,15 @@ function ParentAuthorName({ pubkey }: { pubkey: string }) {
|
||||
}
|
||||
|
||||
export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread }: NoteCardProps) {
|
||||
const cardRef = useRef<HTMLElement>(null);
|
||||
const inView = useInView(cardRef);
|
||||
|
||||
const profile = useProfile(event.pubkey);
|
||||
const rawName = profile?.displayName || profile?.name;
|
||||
const name = (typeof rawName === "string" ? rawName : null) || shortenPubkey(event.pubkey);
|
||||
const avatar = profile?.picture;
|
||||
const nip05 = typeof profile?.nip05 === "string" ? profile.nip05 : null;
|
||||
const verified = useNip05Verified(event.pubkey, nip05);
|
||||
const verified = useNip05Verified(event.pubkey, nip05, inView);
|
||||
const time = event.created_at ? timeAgo(event.created_at) : "";
|
||||
const profileCreatedAt = getProfileAge(event.pubkey);
|
||||
const isNewAccount = profileCreatedAt !== null && (Date.now() / 1000 - profileCreatedAt) < 60 * 24 * 3600;
|
||||
@@ -56,7 +60,6 @@ export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread
|
||||
const pTags = event.tags.filter((t) => t[0] === "p");
|
||||
const parentAuthorPubkey = pTags.length > 0 ? pTags[pTags.length - 1][1] : null;
|
||||
|
||||
const cardRef = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
if (focused) cardRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}, [focused]);
|
||||
@@ -68,7 +71,7 @@ export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread
|
||||
<article
|
||||
ref={cardRef}
|
||||
data-note-id={event.id}
|
||||
className={`border-b border-border px-4 py-3 hover:bg-bg-hover transition-colors cursor-pointer group/card${focused ? " bg-accent/10 border-l-2 border-l-accent" : ""}`}
|
||||
className={`border-b border-border px-4 py-3 hover:bg-bg-hover transition-colors cursor-pointer group/card [content-visibility:auto] [contain-intrinsic-size:auto_120px]${focused ? " bg-accent/10 border-l-2 border-l-accent" : ""}`}
|
||||
onClick={(e) => {
|
||||
// Don't navigate if clicking on interactive elements
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -83,6 +86,8 @@ export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread
|
||||
<img
|
||||
src={avatar}
|
||||
alt={`${name}'s avatar`}
|
||||
width={36}
|
||||
height={36}
|
||||
className="w-9 h-9 rounded-sm object-cover bg-bg-raised ring-1 ring-transparent hover:ring-accent/40 transition-all"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
@@ -183,7 +188,7 @@ export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread
|
||||
<div>
|
||||
<NoteContent content={event.content} inline />
|
||||
</div>
|
||||
<NoteContent content={event.content} mediaOnly />
|
||||
{inView && <NoteContent content={event.content} mediaOnly />}
|
||||
|
||||
{/* Poll options — kind 1068 */}
|
||||
{event.kind === 1068 && <PollWidget event={event} />}
|
||||
@@ -200,11 +205,12 @@ export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread
|
||||
}
|
||||
}}
|
||||
showReply={showReply && !onReplyInThread}
|
||||
enabled={inView}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stats visible when logged out */}
|
||||
{!loggedIn && <LoggedOutStats event={event} />}
|
||||
{!loggedIn && <LoggedOutStats event={event} enabled={inView} />}
|
||||
|
||||
{/* Inline reply box */}
|
||||
{showReply && <InlineReplyBox event={event} name={name} />}
|
||||
|
||||
@@ -164,13 +164,24 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
|
||||
const quoteIds: string[] = segments.filter((s) => s.type === "quote").map((s) => s.value);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
// --- Inline text + images (safe inside clickable wrapper) ---
|
||||
// --- Inline text only (no images — images go in mediaOnly to allow inView gating) ---
|
||||
if (inline) {
|
||||
return (
|
||||
<div>
|
||||
<div className="note-content text-text text-[13px] break-words whitespace-pre-wrap leading-relaxed">
|
||||
{renderTextSegments(segments, openHashtag, { resolveMentions: true })}
|
||||
</div>
|
||||
<div className="note-content text-text text-[13px] break-words whitespace-pre-wrap leading-relaxed">
|
||||
{renderTextSegments(segments, openHashtag, { resolveMentions: true })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Media blocks only (rendered OUTSIDE the clickable wrapper, gated by inView) ---
|
||||
// Images are included here so they only load when the note is near the viewport.
|
||||
if (mediaOnly) {
|
||||
const hasMedia = images.length > 0 || videos.length > 0 || audios.length > 0 || youtubes.length > 0
|
||||
|| vimeos.length > 0 || spotifys.length > 0 || tidals.length > 0 || fountains.length > 0 || quoteIds.length > 0;
|
||||
if (!hasMedia) return null;
|
||||
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ImageGrid images={images} onImageClick={setLightboxIndex} />
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
@@ -180,18 +191,6 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
|
||||
onNavigate={setLightboxIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Media blocks only (rendered OUTSIDE the clickable wrapper) ---
|
||||
if (mediaOnly) {
|
||||
const hasMedia = videos.length > 0 || audios.length > 0 || youtubes.length > 0
|
||||
|| vimeos.length > 0 || spotifys.length > 0 || tidals.length > 0 || fountains.length > 0 || quoteIds.length > 0;
|
||||
if (!hasMedia) return null;
|
||||
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<VideoBlock sources={videos} />
|
||||
<AudioBlock sources={audios} />
|
||||
{youtubes.map((seg, i) => <YouTubeCard key={`yt-${i}`} seg={seg} />)}
|
||||
|
||||
Reference in New Issue
Block a user