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

@@ -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);

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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.