mirror of
https://github.com/hoornet/vega.git
synced 2026-05-09 21:59:12 -07:00
Bump to v0.8.3 — trending feed, NIP-46 remote signer, media feed, profile media gallery
This commit is contained in:
@@ -5,6 +5,7 @@ import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
vi.mock("../lib/nostr", () => ({
|
||||
connectToRelays: vi.fn(),
|
||||
fetchGlobalFeed: vi.fn(),
|
||||
fetchTrendingCandidates: vi.fn(),
|
||||
fetchBatchEngagement: vi.fn(),
|
||||
getNDK: vi.fn(() => ({ pool: { relays: new Map() } })),
|
||||
}));
|
||||
@@ -16,7 +17,7 @@ vi.mock("../lib/db", () => ({
|
||||
}));
|
||||
|
||||
import { useFeedStore } from "./feed";
|
||||
import { fetchGlobalFeed, fetchBatchEngagement } from "../lib/nostr";
|
||||
import { fetchTrendingCandidates, fetchBatchEngagement } from "../lib/nostr";
|
||||
|
||||
function makeMockNote(id: string, created_at: number): NDKEvent {
|
||||
const event = { id, created_at, content: "test", kind: 1, pubkey: "pk", tags: [], sig: "", rawEvent: () => ({ id, created_at, content: "test", kind: 1, pubkey: "pk", tags: [], sig: "" }) } as unknown as NDKEvent;
|
||||
@@ -39,10 +40,11 @@ describe("useFeedStore - loadTrendingFeed", () => {
|
||||
});
|
||||
|
||||
it("scores and sorts notes by engagement", async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const notes = [
|
||||
makeMockNote("a", 1000),
|
||||
makeMockNote("b", 1001),
|
||||
makeMockNote("c", 1002),
|
||||
makeMockNote("a", now - 100),
|
||||
makeMockNote("b", now - 100),
|
||||
makeMockNote("c", now - 100),
|
||||
];
|
||||
|
||||
const engagement = new Map([
|
||||
@@ -51,7 +53,7 @@ describe("useFeedStore - loadTrendingFeed", () => {
|
||||
["c", { reactions: 1, replies: 1, zapSats: 100 }], // score: 5
|
||||
]);
|
||||
|
||||
vi.mocked(fetchGlobalFeed).mockResolvedValue(notes);
|
||||
vi.mocked(fetchTrendingCandidates).mockResolvedValue(notes);
|
||||
vi.mocked(fetchBatchEngagement).mockResolvedValue(engagement);
|
||||
|
||||
await useFeedStore.getState().loadTrendingFeed(true);
|
||||
@@ -64,9 +66,10 @@ describe("useFeedStore - loadTrendingFeed", () => {
|
||||
});
|
||||
|
||||
it("filters out notes with zero engagement", async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const notes = [
|
||||
makeMockNote("a", 1000),
|
||||
makeMockNote("b", 1001),
|
||||
makeMockNote("a", now - 100),
|
||||
makeMockNote("b", now - 100),
|
||||
];
|
||||
|
||||
const engagement = new Map([
|
||||
@@ -74,7 +77,7 @@ describe("useFeedStore - loadTrendingFeed", () => {
|
||||
["b", { reactions: 0, replies: 0, zapSats: 0 }],
|
||||
]);
|
||||
|
||||
vi.mocked(fetchGlobalFeed).mockResolvedValue(notes);
|
||||
vi.mocked(fetchTrendingCandidates).mockResolvedValue(notes);
|
||||
vi.mocked(fetchBatchEngagement).mockResolvedValue(engagement);
|
||||
|
||||
await useFeedStore.getState().loadTrendingFeed(true);
|
||||
@@ -85,12 +88,13 @@ describe("useFeedStore - loadTrendingFeed", () => {
|
||||
});
|
||||
|
||||
it("limits results to 50", async () => {
|
||||
const notes = Array.from({ length: 60 }, (_, i) => makeMockNote(`n${i}`, i));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const notes = Array.from({ length: 60 }, (_, i) => makeMockNote(`n${i}`, now - i));
|
||||
const engagement = new Map(
|
||||
notes.map((n) => [n.id, { reactions: 10, replies: 1, zapSats: 0 }])
|
||||
);
|
||||
|
||||
vi.mocked(fetchGlobalFeed).mockResolvedValue(notes);
|
||||
vi.mocked(fetchTrendingCandidates).mockResolvedValue(notes);
|
||||
vi.mocked(fetchBatchEngagement).mockResolvedValue(engagement);
|
||||
|
||||
await useFeedStore.getState().loadTrendingFeed(true);
|
||||
@@ -99,7 +103,7 @@ describe("useFeedStore - loadTrendingFeed", () => {
|
||||
});
|
||||
|
||||
it("handles empty feed gracefully", async () => {
|
||||
vi.mocked(fetchGlobalFeed).mockResolvedValue([]);
|
||||
vi.mocked(fetchTrendingCandidates).mockResolvedValue([]);
|
||||
|
||||
await useFeedStore.getState().loadTrendingFeed(true);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { connectToRelays, fetchGlobalFeed, fetchBatchEngagement, getNDK } from "../lib/nostr";
|
||||
import { connectToRelays, fetchGlobalFeed, fetchBatchEngagement, fetchTrendingCandidates, getNDK } from "../lib/nostr";
|
||||
import { dbLoadFeed, dbSaveNotes } from "../lib/db";
|
||||
|
||||
const TRENDING_CACHE_KEY = "wrystr_trending_cache";
|
||||
@@ -135,7 +135,7 @@ export const useFeedStore = create<FeedState>((set, get) => ({
|
||||
|
||||
set({ trendingLoading: true });
|
||||
try {
|
||||
const notes = await fetchGlobalFeed(200);
|
||||
const notes = await fetchTrendingCandidates(200, 24);
|
||||
|
||||
if (notes.length === 0) {
|
||||
set({ trendingNotes: [], trendingLoading: false });
|
||||
@@ -145,10 +145,13 @@ export const useFeedStore = create<FeedState>((set, get) => ({
|
||||
const eventIds = notes.map((n) => n.id).filter(Boolean) as string[];
|
||||
const engagement = await fetchBatchEngagement(eventIds);
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const scored = notes
|
||||
.map((note) => {
|
||||
const eng = engagement.get(note.id) ?? { reactions: 0, replies: 0, zapSats: 0 };
|
||||
const score = eng.reactions * 1 + eng.replies * 3 + eng.zapSats * 0.01;
|
||||
const ageHours = (now - (note.created_at ?? now)) / 3600;
|
||||
const decay = 1 / (1 + ageHours * 0.15);
|
||||
const score = (eng.reactions * 1 + eng.replies * 3 + eng.zapSats * 0.01) * decay;
|
||||
return { note, score };
|
||||
})
|
||||
.filter((s) => s.score > 0)
|
||||
|
||||
@@ -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" | "articles" | "about" | "zaps" | "dm" | "notifications" | "bookmarks" | "hashtag";
|
||||
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "articles" | "media" | "about" | "zaps" | "dm" | "notifications" | "bookmarks" | "hashtag";
|
||||
type FeedTab = "global" | "following" | "trending";
|
||||
|
||||
interface UIState {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
|
||||
import { NDKPrivateKeySigner, NDKNip46Signer } from "@nostr-dev-kit/ndk";
|
||||
import { getNDK, publishContactList } from "../lib/nostr";
|
||||
import { nip19 } from "@nostr-dev-kit/ndk";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
@@ -15,7 +15,8 @@ export interface SavedAccount {
|
||||
npub: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
loginType?: "nsec" | "pubkey";
|
||||
loginType?: "nsec" | "pubkey" | "remote-signer";
|
||||
signerPayload?: string;
|
||||
}
|
||||
|
||||
// In-memory signer cache — survives account switches within a session.
|
||||
@@ -23,6 +24,7 @@ export interface SavedAccount {
|
||||
// This means the keychain is only ever consulted at startup (restoreSession),
|
||||
// not on every switch, eliminating the "read-only after switch" class of bugs.
|
||||
const _signerCache = new Map<string, NDKPrivateKeySigner>();
|
||||
const _nip46SignerCache = new Map<string, NDKNip46Signer>();
|
||||
|
||||
function loadSavedAccounts(): SavedAccount[] {
|
||||
try {
|
||||
@@ -53,6 +55,7 @@ interface UserState {
|
||||
|
||||
loginWithNsec: (nsec: string) => Promise<void>;
|
||||
loginWithPubkey: (pubkey: string) => Promise<void>;
|
||||
loginWithRemoteSigner: (bunkerUri: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
restoreSession: () => Promise<void>;
|
||||
switchAccount: (pubkey: string) => Promise<void>;
|
||||
@@ -176,6 +179,50 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
loginWithRemoteSigner: async (bunkerUri: string) => {
|
||||
try {
|
||||
set({ loginError: null });
|
||||
|
||||
const ndk = getNDK();
|
||||
const signer = NDKNip46Signer.bunker(ndk, bunkerUri);
|
||||
|
||||
// Wait for signer with 15s timeout
|
||||
const user = await Promise.race([
|
||||
signer.blockUntilReady(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Remote signer didn't respond within 15 seconds. Check your connection.")), 15000)
|
||||
),
|
||||
]);
|
||||
|
||||
ndk.signer = signer;
|
||||
const pubkey = user.pubkey;
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
|
||||
_nip46SignerCache.set(pubkey, signer);
|
||||
|
||||
const signerPayload = signer.toPayload();
|
||||
const accounts = upsertAccount(get().accounts, { pubkey, npub, loginType: "remote-signer", signerPayload });
|
||||
persistAccounts(accounts);
|
||||
|
||||
set({ pubkey, npub, loggedIn: true, loginError: null, accounts });
|
||||
|
||||
localStorage.setItem("wrystr_pubkey", pubkey);
|
||||
localStorage.setItem("wrystr_login_type", "remote-signer");
|
||||
|
||||
useLightningStore.getState().loadNwcForAccount(pubkey);
|
||||
get().fetchOwnProfile();
|
||||
get().fetchFollows();
|
||||
useMuteStore.getState().fetchMuteList(pubkey);
|
||||
useNotificationsStore.getState().fetchNotifications(pubkey);
|
||||
startNotificationPoller(pubkey);
|
||||
|
||||
useUIStore.getState().setView("feed");
|
||||
useFeedStore.getState().loadFeed();
|
||||
} catch (err) {
|
||||
set({ loginError: `Remote signer login failed: ${err}` });
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
stopNotificationPoller();
|
||||
const ndk = getNDK();
|
||||
@@ -209,6 +256,17 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
|
||||
// Restore NIP-46 signers from saved payloads
|
||||
for (const acct of accounts) {
|
||||
if (acct.loginType !== "remote-signer" || !acct.signerPayload || _nip46SignerCache.has(acct.pubkey)) continue;
|
||||
try {
|
||||
const signer = await NDKNip46Signer.fromPayload(acct.signerPayload, getNDK());
|
||||
_nip46SignerCache.set(acct.pubkey, signer);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to restore NIP-46 session for ${acct.npub}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Now restore the active account
|
||||
const savedPubkey = localStorage.getItem("wrystr_pubkey");
|
||||
const savedLoginType = localStorage.getItem("wrystr_login_type");
|
||||
@@ -219,6 +277,29 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (savedLoginType === "remote-signer") {
|
||||
const cachedSigner = _nip46SignerCache.get(savedPubkey);
|
||||
if (cachedSigner) {
|
||||
try {
|
||||
await cachedSigner.blockUntilReady();
|
||||
getNDK().signer = cachedSigner;
|
||||
const npub = nip19.npubEncode(savedPubkey);
|
||||
set({ pubkey: savedPubkey, npub, loggedIn: true, loginError: null });
|
||||
localStorage.setItem("wrystr_pubkey", savedPubkey);
|
||||
localStorage.setItem("wrystr_login_type", "remote-signer");
|
||||
useLightningStore.getState().loadNwcForAccount(savedPubkey);
|
||||
get().fetchOwnProfile();
|
||||
get().fetchFollows();
|
||||
useMuteStore.getState().fetchMuteList(savedPubkey);
|
||||
useNotificationsStore.getState().fetchNotifications(savedPubkey);
|
||||
startNotificationPoller(savedPubkey);
|
||||
} catch (err) {
|
||||
console.warn("Failed to restore NIP-46 session:", err);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (savedLoginType === "nsec") {
|
||||
const cachedSigner = _signerCache.get(savedPubkey);
|
||||
if (cachedSigner) {
|
||||
@@ -242,6 +323,29 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
// Clear signer immediately — no window where old account could sign
|
||||
getNDK().signer = undefined;
|
||||
|
||||
// Fast path: NIP-46 cached signer
|
||||
const cachedNip46 = _nip46SignerCache.get(pubkey);
|
||||
if (cachedNip46) {
|
||||
try {
|
||||
await cachedNip46.blockUntilReady();
|
||||
getNDK().signer = cachedNip46;
|
||||
const account = get().accounts.find((a) => a.pubkey === pubkey);
|
||||
const npub = account?.npub ?? nip19.npubEncode(pubkey);
|
||||
set({ pubkey, npub, loggedIn: true, loginError: null });
|
||||
localStorage.setItem("wrystr_pubkey", pubkey);
|
||||
localStorage.setItem("wrystr_login_type", "remote-signer");
|
||||
useLightningStore.getState().loadNwcForAccount(pubkey);
|
||||
get().fetchOwnProfile();
|
||||
get().fetchFollows();
|
||||
useMuteStore.getState().fetchMuteList(pubkey);
|
||||
startNotificationPoller(pubkey);
|
||||
useUIStore.getState().setView("feed");
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn("NIP-46 signer reconnect failed during switch:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fast path: reuse in-memory signer cached from the login that added this
|
||||
// account earlier in this session. Avoids a round-trip to the OS keychain
|
||||
// and eliminates the "becomes read-only after switch" failure class.
|
||||
|
||||
Reference in New Issue
Block a user