mirror of
https://github.com/hoornet/vega.git
synced 2026-05-07 20:59:12 -07:00
Bump to v0.2.0 — Phase 2: Engagement & Reach
Four features shipped in this release: - Feed reply context: replies show "↩ replying to @name" above the note content; clicking fetches and opens the parent thread - NIP-65 outbox model: fetchUserRelayList + publishRelayList + fetchUserNotesNIP65 in client.ts; profile notes fetched via the author's write relays; "Publish relay list to Nostr" button in Settings (kind 10002) - Notifications: new store (notifications.ts) + NotificationsView; 🔔 sidebar nav item with unread badge; DM nav item also shows unread conversation count; badges clear on open/select - Keyboard shortcuts: useKeyboardShortcuts hook + HelpModal; n=compose, /=search, j/k=feed nav with ring highlight, Esc=back, ?=help overlay Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,9 +8,11 @@ interface FeedState {
|
||||
loading: boolean;
|
||||
connected: boolean;
|
||||
error: string | null;
|
||||
focusedNoteIndex: number;
|
||||
connect: () => Promise<void>;
|
||||
loadCachedFeed: () => Promise<void>;
|
||||
loadFeed: () => Promise<void>;
|
||||
setFocusedNoteIndex: (n: number) => void;
|
||||
}
|
||||
|
||||
export const useFeedStore = create<FeedState>((set, get) => ({
|
||||
@@ -18,6 +20,8 @@ export const useFeedStore = create<FeedState>((set, get) => ({
|
||||
loading: false,
|
||||
connected: false,
|
||||
error: null,
|
||||
focusedNoteIndex: -1,
|
||||
setFocusedNoteIndex: (n: number) => set({ focusedNoteIndex: n }),
|
||||
|
||||
connect: async () => {
|
||||
try {
|
||||
@@ -55,7 +59,7 @@ export const useFeedStore = create<FeedState>((set, get) => ({
|
||||
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
|
||||
.slice(0, 200);
|
||||
|
||||
set({ notes: merged, loading: false });
|
||||
set({ notes: merged, loading: false, focusedNoteIndex: -1 });
|
||||
|
||||
// Persist fresh notes to SQLite (fire-and-forget)
|
||||
dbSaveNotes(fresh.map((e) => JSON.stringify(e.rawEvent())));
|
||||
|
||||
85
src/stores/notifications.ts
Normal file
85
src/stores/notifications.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { create } from "zustand";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { fetchMentions } from "../lib/nostr";
|
||||
|
||||
const NOTIF_SEEN_KEY = "wrystr_notif_last_seen";
|
||||
const DM_SEEN_KEY = "wrystr_dm_last_seen";
|
||||
|
||||
interface NotificationsState {
|
||||
notifications: NDKEvent[];
|
||||
unreadCount: number;
|
||||
lastSeenAt: number;
|
||||
loading: boolean;
|
||||
currentPubkey: string | null;
|
||||
dmLastSeen: Record<string, number>;
|
||||
dmUnreadCount: number;
|
||||
|
||||
fetchNotifications: (pubkey: string) => Promise<void>;
|
||||
markAllRead: () => void;
|
||||
markDMRead: (partnerPubkey: string) => void;
|
||||
computeDMUnread: (conversations: Array<{ partnerPubkey: string; lastAt: number }>) => void;
|
||||
}
|
||||
|
||||
function loadLastSeen(): number {
|
||||
const stored = parseInt(localStorage.getItem(NOTIF_SEEN_KEY) ?? "0");
|
||||
return stored || Math.floor(Date.now() / 1000) - 86400;
|
||||
}
|
||||
|
||||
function loadDMLastSeen(): Record<string, number> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(DM_SEEN_KEY) ?? "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export const useNotificationsStore = create<NotificationsState>((set, get) => ({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
lastSeenAt: loadLastSeen(),
|
||||
loading: false,
|
||||
currentPubkey: null,
|
||||
dmLastSeen: loadDMLastSeen(),
|
||||
dmUnreadCount: 0,
|
||||
|
||||
fetchNotifications: async (pubkey: string) => {
|
||||
const state = get();
|
||||
const isNewAccount = pubkey !== state.currentPubkey;
|
||||
if (isNewAccount) {
|
||||
set({ notifications: [], currentPubkey: pubkey });
|
||||
}
|
||||
set({ loading: true });
|
||||
try {
|
||||
const lastSeenAt = isNewAccount ? loadLastSeen() : get().lastSeenAt;
|
||||
const events = await fetchMentions(pubkey, lastSeenAt);
|
||||
const unreadCount = events.filter((e) => (e.created_at ?? 0) > lastSeenAt).length;
|
||||
set({ notifications: events, unreadCount, lastSeenAt });
|
||||
} catch {
|
||||
// Non-critical
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
markAllRead: () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
localStorage.setItem(NOTIF_SEEN_KEY, String(now));
|
||||
set({ lastSeenAt: now, unreadCount: 0 });
|
||||
},
|
||||
|
||||
markDMRead: (partnerPubkey: string) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const dmLastSeen = { ...get().dmLastSeen, [partnerPubkey]: now };
|
||||
localStorage.setItem(DM_SEEN_KEY, JSON.stringify(dmLastSeen));
|
||||
set({ dmLastSeen });
|
||||
// dmUnreadCount will be recomputed by computeDMUnread on next DM view render
|
||||
},
|
||||
|
||||
computeDMUnread: (conversations: Array<{ partnerPubkey: string; lastAt: number }>) => {
|
||||
const { dmLastSeen } = get();
|
||||
const dmUnreadCount = conversations.filter(
|
||||
(c) => c.lastAt > (dmLastSeen[c.partnerPubkey] ?? 0)
|
||||
).length;
|
||||
set({ dmUnreadCount });
|
||||
},
|
||||
}));
|
||||
@@ -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";
|
||||
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "about" | "zaps" | "dm" | "notifications";
|
||||
|
||||
interface UIState {
|
||||
currentView: View;
|
||||
@@ -13,6 +13,7 @@ interface UIState {
|
||||
pendingSearch: string | null;
|
||||
pendingDMPubkey: string | null;
|
||||
pendingArticleNaddr: string | null;
|
||||
showHelp: boolean;
|
||||
setView: (view: View) => void;
|
||||
openProfile: (pubkey: string) => void;
|
||||
openThread: (note: NDKEvent, from: View) => void;
|
||||
@@ -21,6 +22,7 @@ interface UIState {
|
||||
openArticle: (naddr: string) => void;
|
||||
goBack: () => void;
|
||||
toggleSidebar: () => void;
|
||||
toggleHelp: () => void;
|
||||
}
|
||||
|
||||
const SIDEBAR_KEY = "wrystr_sidebar_collapsed";
|
||||
@@ -34,6 +36,7 @@ export const useUIStore = create<UIState>((set, _get) => ({
|
||||
pendingSearch: null,
|
||||
pendingDMPubkey: null,
|
||||
pendingArticleNaddr: null,
|
||||
showHelp: false,
|
||||
setView: (currentView) => set({ currentView }),
|
||||
openProfile: (pubkey) => set((s) => ({ currentView: "profile", selectedPubkey: pubkey, previousView: s.currentView as View })),
|
||||
openThread: (note, from) => set({ currentView: "thread", selectedNote: note, previousView: from }),
|
||||
@@ -41,6 +44,7 @@ export const useUIStore = create<UIState>((set, _get) => ({
|
||||
openDM: (pubkey) => set({ currentView: "dm", pendingDMPubkey: pubkey }),
|
||||
openArticle: (naddr) => set((s) => ({ currentView: "article", pendingArticleNaddr: naddr, previousView: s.currentView as View })),
|
||||
goBack: () => set((s) => ({
|
||||
showHelp: false,
|
||||
currentView: s.previousView !== s.currentView ? s.previousView : "feed",
|
||||
selectedNote: null,
|
||||
})),
|
||||
@@ -49,4 +53,5 @@ export const useUIStore = create<UIState>((set, _get) => ({
|
||||
localStorage.setItem(SIDEBAR_KEY, String(next));
|
||||
return { sidebarCollapsed: next };
|
||||
}),
|
||||
toggleHelp: () => set((s) => ({ showHelp: !s.showHelp })),
|
||||
}));
|
||||
|
||||
@@ -6,6 +6,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { useMuteStore } from "./mute";
|
||||
import { useLightningStore } from "./lightning";
|
||||
import { useUIStore } from "./ui";
|
||||
import { useNotificationsStore } from "./notifications";
|
||||
|
||||
export interface SavedAccount {
|
||||
pubkey: string;
|
||||
@@ -112,10 +113,11 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
// Load per-account NWC wallet
|
||||
useLightningStore.getState().loadNwcForAccount(pubkey);
|
||||
|
||||
// Fetch profile, follows, and mute list
|
||||
// Fetch profile, follows, mute list, and notifications
|
||||
get().fetchOwnProfile();
|
||||
get().fetchFollows();
|
||||
useMuteStore.getState().fetchMuteList(pubkey);
|
||||
useNotificationsStore.getState().fetchNotifications(pubkey);
|
||||
} catch (err) {
|
||||
set({ loginError: `Login failed: ${err}` });
|
||||
}
|
||||
@@ -154,6 +156,7 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
get().fetchOwnProfile();
|
||||
get().fetchFollows();
|
||||
useMuteStore.getState().fetchMuteList(pubkey);
|
||||
useNotificationsStore.getState().fetchNotifications(pubkey);
|
||||
} catch (err) {
|
||||
set({ loginError: `Login failed: ${err}` });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user