mirror of
https://github.com/hoornet/vega.git
synced 2026-04-30 01:29:59 -07:00
Bump to v0.4.0 — Phase 3: image lightbox, bookmarks, discover, language filter, UI polish
This commit is contained in:
@@ -13,6 +13,7 @@ import { AboutView } from "./components/shared/AboutView";
|
||||
import { ZapHistoryView } from "./components/zap/ZapHistoryView";
|
||||
import { DMView } from "./components/dm/DMView";
|
||||
import { NotificationsView } from "./components/notifications/NotificationsView";
|
||||
import { BookmarkView } from "./components/bookmark/BookmarkView";
|
||||
import { HelpModal } from "./components/shared/HelpModal";
|
||||
import { useUIStore } from "./stores/ui";
|
||||
import { useUpdater } from "./hooks/useUpdater";
|
||||
@@ -73,6 +74,7 @@ function App() {
|
||||
{currentView === "zaps" && <ZapHistoryView />}
|
||||
{currentView === "dm" && <DMView />}
|
||||
{currentView === "notifications" && <NotificationsView />}
|
||||
{currentView === "bookmarks" && <BookmarkView />}
|
||||
</main>
|
||||
</div>
|
||||
{showHelp && <HelpModal onClose={toggleHelp} />}
|
||||
|
||||
72
src/components/bookmark/BookmarkView.tsx
Normal file
72
src/components/bookmark/BookmarkView.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useBookmarkStore } from "../../stores/bookmark";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { fetchNoteById } from "../../lib/nostr";
|
||||
import { NoteCard } from "../feed/NoteCard";
|
||||
import { SkeletonNoteList } from "../shared/Skeleton";
|
||||
|
||||
export function BookmarkView() {
|
||||
const { bookmarkedIds, fetchBookmarks } = useBookmarkStore();
|
||||
const { pubkey } = useUserStore();
|
||||
const [notes, setNotes] = useState<NDKEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (pubkey) fetchBookmarks(pubkey);
|
||||
}, [pubkey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bookmarkedIds.length === 0) {
|
||||
setNotes([]);
|
||||
return;
|
||||
}
|
||||
loadNotes();
|
||||
}, [bookmarkedIds]);
|
||||
|
||||
const loadNotes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
bookmarkedIds.map((id) => fetchNoteById(id))
|
||||
);
|
||||
setNotes(
|
||||
results
|
||||
.filter((e): e is NDKEvent => e !== null)
|
||||
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<header className="border-b border-border px-4 py-2.5 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-text text-[13px] font-medium">Bookmarks</h2>
|
||||
<span className="text-text-dim text-[11px]">{bookmarkedIds.length} saved</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && notes.length === 0 && (
|
||||
<SkeletonNoteList count={3} />
|
||||
)}
|
||||
|
||||
{!loading && notes.length === 0 && (
|
||||
<div className="px-4 py-12 text-center space-y-2">
|
||||
<p className="text-text-dim text-[13px]">No bookmarks yet.</p>
|
||||
<p className="text-text-dim text-[11px] opacity-60">
|
||||
Use the <span className="text-accent">save</span> button on any note to bookmark it here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notes.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,15 +4,17 @@ import { useUserStore } from "../../stores/user";
|
||||
import { useMuteStore } from "../../stores/mute";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { fetchFollowFeed, getNDK } from "../../lib/nostr";
|
||||
import { detectScript, getEventLanguageTag, FILTER_SCRIPTS } from "../../lib/language";
|
||||
import { NoteCard } from "./NoteCard";
|
||||
import { ComposeBox } from "./ComposeBox";
|
||||
import { SkeletonNoteList } from "../shared/Skeleton";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
|
||||
export function Feed() {
|
||||
const { notes, loading, connected, error, connect, loadCachedFeed, loadFeed, focusedNoteIndex } = useFeedStore();
|
||||
const { loggedIn, follows } = useUserStore();
|
||||
const { mutedPubkeys } = useMuteStore();
|
||||
const { feedTab: tab, setFeedTab: setTab } = useUIStore();
|
||||
const { feedTab: tab, setFeedTab: setTab, feedLanguageFilter, setFeedLanguageFilter } = useUIStore();
|
||||
const [followNotes, setFollowNotes] = useState<NDKEvent[]>([]);
|
||||
const [followLoading, setFollowLoading] = useState(false);
|
||||
|
||||
@@ -49,6 +51,29 @@ export function Feed() {
|
||||
// Filter out notes that look like base64 blobs or relay protocol messages
|
||||
if (c.length > 500 && /^[A-Za-z0-9+/=]{50,}$/.test(c.slice(0, 100))) return false;
|
||||
if (c.startsWith("nlogpost:") || c.startsWith("T1772")) return false;
|
||||
// Language/script filter
|
||||
if (feedLanguageFilter) {
|
||||
const langTag = getEventLanguageTag(event.tags);
|
||||
if (langTag) {
|
||||
// Map ISO-639-1 codes to script names for comparison
|
||||
const langToScript: Record<string, string> = {
|
||||
en: "Latin", es: "Latin", fr: "Latin", de: "Latin", pt: "Latin", it: "Latin", nl: "Latin", pl: "Latin", sv: "Latin", da: "Latin", no: "Latin", fi: "Latin", ro: "Latin", tr: "Latin", cs: "Latin", hr: "Latin", hu: "Latin",
|
||||
zh: "CJK", ja: "CJK",
|
||||
ko: "Korean",
|
||||
ru: "Cyrillic", uk: "Cyrillic", bg: "Cyrillic", sr: "Cyrillic",
|
||||
ar: "Arabic", fa: "Arabic", ur: "Arabic",
|
||||
hi: "Devanagari", mr: "Devanagari", ne: "Devanagari",
|
||||
th: "Thai",
|
||||
he: "Hebrew",
|
||||
el: "Greek",
|
||||
};
|
||||
const script = langToScript[langTag];
|
||||
if (script && script !== feedLanguageFilter) return false;
|
||||
} else {
|
||||
const script = detectScript(c);
|
||||
if (script !== feedLanguageFilter) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -81,6 +106,16 @@ export function Feed() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={feedLanguageFilter ?? ""}
|
||||
onChange={(e) => setFeedLanguageFilter(e.target.value || null)}
|
||||
className="bg-transparent text-text-dim text-[11px] border border-border px-1.5 py-0.5 focus:outline-none hover:border-text-dim transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="">all scripts</option>
|
||||
{FILTER_SCRIPTS.map((s) => (
|
||||
<option key={s} value={s}>{s.toLowerCase()}</option>
|
||||
))}
|
||||
</select>
|
||||
{connected && (
|
||||
<span className="text-success text-[11px] flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success inline-block" />
|
||||
@@ -109,16 +144,25 @@ export function Feed() {
|
||||
)}
|
||||
|
||||
{isLoading && filteredNotes.length === 0 && (
|
||||
<div className="px-4 py-8 text-text-dim text-[12px] text-center">
|
||||
{isFollowing ? "Loading notes from people you follow…" : "Connecting to relays…"}
|
||||
</div>
|
||||
<SkeletonNoteList count={6} />
|
||||
)}
|
||||
|
||||
{!isLoading && filteredNotes.length === 0 && (
|
||||
<div className="px-4 py-8 text-text-dim text-[12px] text-center">
|
||||
{isFollowing && follows.length === 0
|
||||
? "You're not following anyone yet."
|
||||
: "No notes yet."}
|
||||
<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."}
|
||||
</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."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useReactionCount } from "../../hooks/useReactionCount";
|
||||
import { useZapCount } from "../../hooks/useZapCount";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useMuteStore } from "../../stores/mute";
|
||||
import { useBookmarkStore } from "../../stores/bookmark";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { timeAgo, shortenPubkey } from "../../lib/utils";
|
||||
import { publishReaction, publishReply, publishRepost, getNDK, fetchNoteById } from "../../lib/nostr";
|
||||
@@ -41,6 +42,8 @@ export function NoteCard({ event, focused }: NoteCardProps) {
|
||||
const { loggedIn, pubkey: ownPubkey } = useUserStore();
|
||||
const { mutedPubkeys, mute, unmute } = useMuteStore();
|
||||
const isMuted = mutedPubkeys.includes(event.pubkey);
|
||||
const { bookmarkedIds, addBookmark, removeBookmark } = useBookmarkStore();
|
||||
const isBookmarked = bookmarkedIds.includes(event.id!);
|
||||
const { openProfile, openThread, currentView } = useUIStore();
|
||||
|
||||
const parentEventId = getParentEventId(event);
|
||||
@@ -261,6 +264,14 @@ export function NoteCard({ event, focused }: NoteCardProps) {
|
||||
: "⚡ zap"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => isBookmarked ? removeBookmark(event.id!) : addBookmark(event.id!)}
|
||||
className={`text-[11px] transition-colors ${
|
||||
isBookmarked ? "text-accent" : "text-text-dim hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
{isBookmarked ? "▪ saved" : "▫ save"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useUIStore } from "../../stores/ui";
|
||||
import { fetchNoteById } from "../../lib/nostr";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { shortenPubkey } from "../../lib/utils";
|
||||
import { ImageLightbox } from "../shared/ImageLightbox";
|
||||
|
||||
// Regex patterns
|
||||
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
|
||||
@@ -209,6 +210,7 @@ export function NoteContent({ content }: { content: string }) {
|
||||
const images: string[] = segments.filter((s) => s.type === "image").map((s) => s.value);
|
||||
const videos: string[] = segments.filter((s) => s.type === "video").map((s) => s.value);
|
||||
const quoteIds: string[] = segments.filter((s) => s.type === "quote").map((s) => s.value);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
const inlineElements: ReactNode[] = [];
|
||||
|
||||
@@ -284,7 +286,8 @@ export function NoteContent({ content }: { content: string }) {
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="max-w-full max-h-80 rounded-sm object-cover bg-bg-raised border border-border"
|
||||
className="max-w-full max-h-80 rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); setLightboxIndex(i); }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
@@ -293,6 +296,15 @@ export function NoteContent({ content }: { content: string }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
images={images}
|
||||
index={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
onNavigate={setLightboxIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quoted notes */}
|
||||
{quoteIds.map((id) => (
|
||||
<QuotePreview key={id} eventId={id} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useNotificationsStore } from "../../stores/notifications";
|
||||
import { NoteCard } from "../feed/NoteCard";
|
||||
import { SkeletonNoteList } from "../shared/Skeleton";
|
||||
|
||||
export function NotificationsView() {
|
||||
const { pubkey, loggedIn } = useUserStore();
|
||||
@@ -48,14 +49,13 @@ export function NotificationsView() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && notifications.length === 0 && (
|
||||
<div className="px-4 py-8 text-text-dim text-[12px] text-center">
|
||||
Loading notifications…
|
||||
</div>
|
||||
<SkeletonNoteList count={4} />
|
||||
)}
|
||||
|
||||
{!loading && notifications.length === 0 && (
|
||||
<div className="px-4 py-8 text-text-dim text-[12px] text-center">
|
||||
No mentions yet.
|
||||
<div className="px-4 py-12 text-center space-y-2">
|
||||
<p className="text-text-dim text-[13px]">No mentions yet.</p>
|
||||
<p className="text-text-dim text-[11px] opacity-60">When someone mentions you, it will appear here.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { searchNotes, searchUsers, getStoredRelayUrls } from "../../lib/nostr";
|
||||
import { searchNotes, searchUsers, getStoredRelayUrls, fetchFollowSuggestions, fetchProfile } from "../../lib/nostr";
|
||||
import { getNip50Relays } from "../../lib/nostr/relayInfo";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
@@ -81,6 +81,44 @@ function UserRow({ user }: { user: ParsedUser }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface Suggestion {
|
||||
pubkey: string;
|
||||
mutualCount: number;
|
||||
profile: ParsedUser | null;
|
||||
}
|
||||
|
||||
function SuggestionFollowButton({ pubkey }: { pubkey: string }) {
|
||||
const { loggedIn, follows, follow, unfollow } = useUserStore();
|
||||
const isFollowing = follows.includes(pubkey);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
if (!loggedIn) return null;
|
||||
|
||||
const handleClick = async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
if (isFollowing) await unfollow(pubkey);
|
||||
else await follow(pubkey);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={pending}
|
||||
className={`text-[11px] px-3 py-1 border transition-colors shrink-0 disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
isFollowing
|
||||
? "border-border text-text-muted hover:text-danger hover:border-danger/40"
|
||||
: "border-accent/60 text-accent hover:bg-accent hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{pending ? "..." : isFollowing ? "unfollow" : "follow"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchView() {
|
||||
const { pendingSearch } = useUIStore();
|
||||
const [query, setQuery] = useState(pendingSearch ?? "");
|
||||
@@ -91,6 +129,9 @@ export function SearchView() {
|
||||
const [activeTab, setActiveTab] = useState<"notes" | "people">("notes");
|
||||
const [nip50Relays, setNip50Relays] = useState<string[] | null>(null); // null = not checked yet
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const [suggestionsLoading, setSuggestionsLoading] = useState(false);
|
||||
const [suggestionsLoaded, setSuggestionsLoaded] = useState(false);
|
||||
|
||||
const isHashtag = query.trim().startsWith("#");
|
||||
|
||||
@@ -100,6 +141,40 @@ export function SearchView() {
|
||||
getNip50Relays(urls).then(setNip50Relays);
|
||||
}, []);
|
||||
|
||||
const { loggedIn, follows } = useUserStore();
|
||||
|
||||
// Load follow suggestions on mount (only for logged-in users with follows)
|
||||
useEffect(() => {
|
||||
if (!loggedIn || follows.length === 0 || suggestionsLoaded) return;
|
||||
setSuggestionsLoading(true);
|
||||
fetchFollowSuggestions(follows).then(async (results) => {
|
||||
// Load profiles for top suggestions
|
||||
const withProfiles: Suggestion[] = await Promise.all(
|
||||
results.slice(0, 20).map(async (s) => {
|
||||
try {
|
||||
const p = await fetchProfile(s.pubkey);
|
||||
return {
|
||||
...s,
|
||||
profile: p ? {
|
||||
pubkey: s.pubkey,
|
||||
name: (p as Record<string, string>).name || "",
|
||||
displayName: (p as Record<string, string>).display_name || (p as Record<string, string>).name || "",
|
||||
picture: (p as Record<string, string>).picture || "",
|
||||
nip05: (p as Record<string, string>).nip05 || "",
|
||||
about: (p as Record<string, string>).about || "",
|
||||
} : null,
|
||||
};
|
||||
} catch {
|
||||
return { ...s, profile: null };
|
||||
}
|
||||
})
|
||||
);
|
||||
setSuggestions(withProfiles.filter((s) => s.profile !== null));
|
||||
setSuggestionsLoading(false);
|
||||
setSuggestionsLoaded(true);
|
||||
}).catch(() => setSuggestionsLoading(false));
|
||||
}, [loggedIn, follows.length]);
|
||||
|
||||
// Run pending search from hashtag/mention click
|
||||
useEffect(() => {
|
||||
if (pendingSearch) {
|
||||
@@ -212,6 +287,51 @@ export function SearchView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discover — follow suggestions */}
|
||||
{!searched && !loading && loggedIn && (
|
||||
<div className="border-t border-border">
|
||||
<div className="px-4 py-2.5 border-b border-border">
|
||||
<h3 className="text-text text-[12px] font-medium">Discover people</h3>
|
||||
<p className="text-text-dim text-[10px] mt-0.5">Based on who your follows follow</p>
|
||||
</div>
|
||||
{suggestionsLoading && (
|
||||
<div className="px-4 py-6 text-text-dim text-[11px] text-center">Finding suggestions...</div>
|
||||
)}
|
||||
{suggestions.map((s) => s.profile && (
|
||||
<div key={s.pubkey} className="flex items-center gap-3 px-4 py-2.5 border-b border-border hover:bg-bg-hover transition-colors">
|
||||
<div className="shrink-0 cursor-pointer" onClick={() => useUIStore.getState().openProfile(s.pubkey)}>
|
||||
{s.profile.picture ? (
|
||||
<img src={s.profile.picture} alt="" className="w-9 h-9 rounded-sm object-cover bg-bg-raised"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-xs">
|
||||
{(s.profile.displayName || s.profile.name || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 cursor-pointer" onClick={() => useUIStore.getState().openProfile(s.pubkey)}>
|
||||
<div className="text-text text-[13px] font-medium truncate">
|
||||
{s.profile.displayName || s.profile.name || shortenPubkey(s.pubkey)}
|
||||
</div>
|
||||
<div className="text-text-dim text-[10px]">
|
||||
{s.mutualCount} mutual follow{s.mutualCount !== 1 ? "s" : ""}
|
||||
{s.profile.nip05 && <span className="ml-2">{s.profile.nip05}</span>}
|
||||
</div>
|
||||
{s.profile.about && (
|
||||
<div className="text-text-dim text-[11px] truncate mt-0.5">{s.profile.about}</div>
|
||||
)}
|
||||
</div>
|
||||
<SuggestionFollowButton pubkey={s.pubkey} />
|
||||
</div>
|
||||
))}
|
||||
{suggestionsLoaded && suggestions.length === 0 && (
|
||||
<div className="px-4 py-6 text-text-dim text-[11px] text-center">
|
||||
Follow more people to see suggestions here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zero results for full-text search */}
|
||||
{searched && totalResults === 0 && !isHashtag && (
|
||||
<div className="px-4 py-8 text-center space-y-3">
|
||||
|
||||
75
src/components/shared/ImageLightbox.tsx
Normal file
75
src/components/shared/ImageLightbox.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
|
||||
interface ImageLightboxProps {
|
||||
images: string[];
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
onNavigate: (index: number) => void;
|
||||
}
|
||||
|
||||
export function ImageLightbox({ images, index, onClose, onNavigate }: ImageLightboxProps) {
|
||||
const hasPrev = index > 0;
|
||||
const hasNext = index < images.length - 1;
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "ArrowLeft" && hasPrev) onNavigate(index - 1);
|
||||
if (e.key === "ArrowRight" && hasNext) onNavigate(index + 1);
|
||||
}, [onClose, onNavigate, index, hasPrev, hasNext]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-white/70 hover:text-white text-[20px] w-8 h-8 flex items-center justify-center transition-colors z-10"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
{/* Counter */}
|
||||
{images.length > 1 && (
|
||||
<div className="absolute top-4 left-4 text-white/50 text-[12px] z-10">
|
||||
{index + 1} / {images.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prev arrow */}
|
||||
{hasPrev && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(index - 1); }}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white text-[28px] w-10 h-10 flex items-center justify-center transition-colors z-10"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Image */}
|
||||
<img
|
||||
src={images[index]}
|
||||
alt=""
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain select-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* Next arrow */}
|
||||
{hasNext && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(index + 1); }}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white text-[28px] w-10 h-10 flex items-center justify-center transition-colors z-10"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/shared/Skeleton.tsx
Normal file
46
src/components/shared/Skeleton.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
export function SkeletonNote() {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border animate-pulse">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-9 h-9 rounded-sm bg-bg-raised shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-24 bg-bg-raised rounded-sm" />
|
||||
<div className="h-3 w-12 bg-bg-raised rounded-sm" />
|
||||
</div>
|
||||
<div className="h-3 w-full bg-bg-raised rounded-sm" />
|
||||
<div className="h-3 w-3/4 bg-bg-raised rounded-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonNoteList({ count = 5 }: { count?: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<SkeletonNote key={i} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonProfile() {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-32 bg-bg-raised" />
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-16 h-16 rounded-sm bg-bg-raised border-2 border-bg -mt-10" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="h-4 w-32 bg-bg-raised rounded-sm" />
|
||||
<div className="h-3 w-20 bg-bg-raised rounded-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-3 w-full bg-bg-raised rounded-sm" />
|
||||
<div className="h-3 w-2/3 bg-bg-raised rounded-sm" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import pkg from "../../../package.json";
|
||||
const NAV_ITEMS = [
|
||||
{ id: "feed" as const, label: "feed", icon: "◈" },
|
||||
{ id: "search" as const, label: "search", icon: "⌕" },
|
||||
{ id: "bookmarks" as const, label: "bookmarks", icon: "▪" },
|
||||
{ id: "dm" as const, label: "messages", icon: "✉" },
|
||||
{ id: "notifications" as const, label: "notifications", icon: "🔔" },
|
||||
{ id: "zaps" as const, label: "zaps", icon: "⚡" },
|
||||
|
||||
@@ -84,6 +84,15 @@ body {
|
||||
.prose-article strong { color: var(--color-text); font-weight: 600; }
|
||||
.prose-article img { max-width: 100%; border-radius: 2px; margin: 1em 0; }
|
||||
|
||||
/* View transition fade-in */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.view-fade-in {
|
||||
animation: fade-in 150ms ease-out;
|
||||
}
|
||||
|
||||
/* Scrollbar — thin, minimal */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
71
src/lib/language.ts
Normal file
71
src/lib/language.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Unicode script detection for feed filtering
|
||||
|
||||
const SCRIPT_RANGES: [string, RegExp][] = [
|
||||
["Latin", /[\u0041-\u024F\u1E00-\u1EFF]/],
|
||||
["CJK", /[\u2E80-\u2FFF\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFFEF]|[\uD840-\uD87F][\uDC00-\uDFFF]/],
|
||||
["Cyrillic", /[\u0400-\u04FF\u0500-\u052F]/],
|
||||
["Arabic", /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF]/],
|
||||
["Devanagari", /[\u0900-\u097F]/],
|
||||
["Thai", /[\u0E00-\u0E7F]/],
|
||||
["Korean", /[\uAC00-\uD7AF\u1100-\u11FF]/],
|
||||
["Hebrew", /[\u0590-\u05FF]/],
|
||||
["Greek", /[\u0370-\u03FF]/],
|
||||
["Georgian", /[\u10A0-\u10FF]/],
|
||||
["Armenian", /[\u0530-\u058F]/],
|
||||
];
|
||||
|
||||
export function detectScript(text: string): string {
|
||||
// Strip URLs, mentions, hashtags to avoid noise
|
||||
const cleaned = text
|
||||
.replace(/https?:\/\/\S+/g, "")
|
||||
.replace(/nostr:\S+/g, "")
|
||||
.replace(/#\w+/g, "")
|
||||
.trim();
|
||||
|
||||
if (!cleaned) return "Unknown";
|
||||
|
||||
// Count characters per script
|
||||
const counts = new Map<string, number>();
|
||||
for (const char of cleaned) {
|
||||
for (const [name, regex] of SCRIPT_RANGES) {
|
||||
if (regex.test(char)) {
|
||||
counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (counts.size === 0) return "Unknown";
|
||||
|
||||
// Return dominant script
|
||||
let maxScript = "Unknown";
|
||||
let maxCount = 0;
|
||||
for (const [script, count] of counts) {
|
||||
if (count > maxCount) {
|
||||
maxScript = script;
|
||||
maxCount = count;
|
||||
}
|
||||
}
|
||||
|
||||
return maxScript;
|
||||
}
|
||||
|
||||
// Check NIP-32 language tags on an event
|
||||
export function getEventLanguageTag(tags: string[][]): string | null {
|
||||
const langTag = tags.find(
|
||||
(t) => t[0] === "l" && t[2] === "ISO-639-1"
|
||||
);
|
||||
return langTag?.[1] ?? null;
|
||||
}
|
||||
|
||||
export const FILTER_SCRIPTS = [
|
||||
"Latin",
|
||||
"CJK",
|
||||
"Cyrillic",
|
||||
"Arabic",
|
||||
"Devanagari",
|
||||
"Thai",
|
||||
"Korean",
|
||||
"Hebrew",
|
||||
"Greek",
|
||||
] as const;
|
||||
@@ -445,6 +445,29 @@ export async function fetchZapsSent(pubkey: string, limit = 50): Promise<NDKEven
|
||||
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
|
||||
}
|
||||
|
||||
// ── Bookmarks (NIP-51 kind 10003) ────────────────────────────────────────────
|
||||
|
||||
export async function fetchBookmarkList(pubkey: string): Promise<string[]> {
|
||||
const instance = getNDK();
|
||||
const filter: NDKFilter = { kinds: [10003 as NDKKind], authors: [pubkey], limit: 1 };
|
||||
const events = await instance.fetchEvents(filter, {
|
||||
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
|
||||
});
|
||||
if (events.size === 0) return [];
|
||||
const event = Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0];
|
||||
return event.tags.filter((t) => t[0] === "e" && t[1]).map((t) => t[1]);
|
||||
}
|
||||
|
||||
export async function publishBookmarkList(eventIds: string[]): Promise<void> {
|
||||
const instance = getNDK();
|
||||
if (!instance.signer) return;
|
||||
const event = new NDKEvent(instance);
|
||||
event.kind = 10003 as NDKKind;
|
||||
event.content = "";
|
||||
event.tags = eventIds.map((id) => ["e", id]);
|
||||
await event.publish();
|
||||
}
|
||||
|
||||
export async function fetchMuteList(pubkey: string): Promise<string[]> {
|
||||
const instance = getNDK();
|
||||
const filter: NDKFilter = { kinds: [10000 as NDKKind], authors: [pubkey], limit: 1 };
|
||||
@@ -520,6 +543,44 @@ export async function fetchUserNotesNIP65(pubkey: string, limit = 30): Promise<N
|
||||
|
||||
// ── Notifications (mentions) ──────────────────────────────────────────────────
|
||||
|
||||
// ── Follow Suggestions (follows-of-follows) ─────────────────────────────────
|
||||
|
||||
export async function fetchFollowSuggestions(myFollows: string[]): Promise<{ pubkey: string; mutualCount: number }[]> {
|
||||
if (myFollows.length === 0) return [];
|
||||
const instance = getNDK();
|
||||
// Fetch contact lists (kind 3) from our follows
|
||||
const batchSize = 20;
|
||||
const allContactEvents: NDKEvent[] = [];
|
||||
for (let i = 0; i < myFollows.length; i += batchSize) {
|
||||
const batch = myFollows.slice(i, i + batchSize);
|
||||
const filter: NDKFilter = { kinds: [3 as NDKKind], authors: batch, limit: batch.length };
|
||||
const events = await instance.fetchEvents(filter, {
|
||||
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
|
||||
});
|
||||
allContactEvents.push(...Array.from(events));
|
||||
}
|
||||
|
||||
// Count how many of our follows follow each pubkey
|
||||
const myFollowSet = new Set(myFollows);
|
||||
const counts = new Map<string, number>();
|
||||
for (const event of allContactEvents) {
|
||||
const pubkeys = event.tags.filter((t) => t[0] === "p" && t[1]).map((t) => t[1]);
|
||||
for (const pk of pubkeys) {
|
||||
if (myFollowSet.has(pk)) continue; // already following
|
||||
counts.set(pk, (counts.get(pk) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove self
|
||||
const myPubkey = (await instance.signer?.user())?.pubkey;
|
||||
if (myPubkey) counts.delete(myPubkey);
|
||||
|
||||
return Array.from(counts.entries())
|
||||
.map(([pubkey, mutualCount]) => ({ pubkey, mutualCount }))
|
||||
.sort((a, b) => b.mutualCount - a.mutualCount)
|
||||
.slice(0, 30);
|
||||
}
|
||||
|
||||
export async function fetchMentions(pubkey: string, since: number, limit = 50): Promise<NDKEvent[]> {
|
||||
const instance = getNDK();
|
||||
const events = await instance.fetchEvents(
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchReactionCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchMentions } from "./client";
|
||||
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchReactionCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions } from "./client";
|
||||
export type { UserRelayList } from "./client";
|
||||
|
||||
61
src/stores/bookmark.ts
Normal file
61
src/stores/bookmark.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { create } from "zustand";
|
||||
import { fetchBookmarkList, publishBookmarkList } from "../lib/nostr";
|
||||
|
||||
const STORAGE_KEY = "wrystr_bookmarks";
|
||||
|
||||
function loadLocal(): string[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveLocal(ids: string[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
|
||||
}
|
||||
|
||||
interface BookmarkState {
|
||||
bookmarkedIds: string[];
|
||||
fetchBookmarks: (pubkey: string) => Promise<void>;
|
||||
addBookmark: (eventId: string) => Promise<void>;
|
||||
removeBookmark: (eventId: string) => Promise<void>;
|
||||
isBookmarked: (eventId: string) => boolean;
|
||||
}
|
||||
|
||||
export const useBookmarkStore = create<BookmarkState>((set, get) => ({
|
||||
bookmarkedIds: loadLocal(),
|
||||
|
||||
fetchBookmarks: async (pubkey: string) => {
|
||||
try {
|
||||
const ids = await fetchBookmarkList(pubkey);
|
||||
if (ids.length === 0) return;
|
||||
const local = get().bookmarkedIds;
|
||||
const merged = Array.from(new Set([...ids, ...local]));
|
||||
set({ bookmarkedIds: merged });
|
||||
saveLocal(merged);
|
||||
} catch {
|
||||
// Non-critical — local bookmarks still work
|
||||
}
|
||||
},
|
||||
|
||||
addBookmark: async (eventId: string) => {
|
||||
const { bookmarkedIds } = get();
|
||||
if (bookmarkedIds.includes(eventId)) return;
|
||||
const updated = [...bookmarkedIds, eventId];
|
||||
set({ bookmarkedIds: updated });
|
||||
saveLocal(updated);
|
||||
publishBookmarkList(updated).catch(() => {});
|
||||
},
|
||||
|
||||
removeBookmark: async (eventId: string) => {
|
||||
const updated = get().bookmarkedIds.filter((id) => id !== eventId);
|
||||
set({ bookmarkedIds: updated });
|
||||
saveLocal(updated);
|
||||
publishBookmarkList(updated).catch(() => {});
|
||||
},
|
||||
|
||||
isBookmarked: (eventId: string) => {
|
||||
return get().bookmarkedIds.includes(eventId);
|
||||
},
|
||||
}));
|
||||
@@ -2,7 +2,7 @@ import { create } from "zustand";
|
||||
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
|
||||
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "about" | "zaps" | "dm" | "notifications";
|
||||
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "about" | "zaps" | "dm" | "notifications" | "bookmarks";
|
||||
type FeedTab = "global" | "following";
|
||||
|
||||
interface UIState {
|
||||
@@ -16,6 +16,7 @@ interface UIState {
|
||||
pendingDMPubkey: string | null;
|
||||
pendingArticleNaddr: string | null;
|
||||
showHelp: boolean;
|
||||
feedLanguageFilter: string | null;
|
||||
setView: (view: View) => void;
|
||||
setFeedTab: (tab: FeedTab) => void;
|
||||
openProfile: (pubkey: string) => void;
|
||||
@@ -24,6 +25,7 @@ interface UIState {
|
||||
openDM: (pubkey: string) => void;
|
||||
openArticle: (naddr: string) => void;
|
||||
goBack: () => void;
|
||||
setFeedLanguageFilter: (filter: string | null) => void;
|
||||
toggleSidebar: () => void;
|
||||
toggleHelp: () => void;
|
||||
}
|
||||
@@ -41,6 +43,7 @@ export const useUIStore = create<UIState>((set, _get) => ({
|
||||
pendingDMPubkey: null,
|
||||
pendingArticleNaddr: null,
|
||||
showHelp: false,
|
||||
feedLanguageFilter: null,
|
||||
setView: (currentView) => set({ currentView }),
|
||||
setFeedTab: (feedTab) => set({ feedTab }),
|
||||
openProfile: (pubkey) => set((s) => ({ currentView: "profile", selectedPubkey: pubkey, previousView: s.currentView as View })),
|
||||
@@ -53,6 +56,7 @@ export const useUIStore = create<UIState>((set, _get) => ({
|
||||
currentView: s.previousView !== s.currentView ? s.previousView : "feed",
|
||||
selectedNote: null,
|
||||
})),
|
||||
setFeedLanguageFilter: (feedLanguageFilter) => set({ feedLanguageFilter }),
|
||||
toggleSidebar: () => set((s) => {
|
||||
const next = !s.sidebarCollapsed;
|
||||
localStorage.setItem(SIDEBAR_KEY, String(next));
|
||||
|
||||
Reference in New Issue
Block a user