Grouped emoji reactions, npub search, notification fix, dev logger

- Emoji reactions now display as grouped pills (❤️5 🤙3 🔥2) instead
  of a single aggregated count. Multi-reaction per note supported.
- Throttled reaction fetch queue (max 4 concurrent) prevents relay overload.
- Searching a bare npub/nprofile navigates directly to that profile.
- Notification poller waits for relay connection before first fetch,
  fixing empty results on startup.
- Dev-only debug logger (src/lib/debug.ts) — silent in production builds.
This commit is contained in:
Jure
2026-03-27 18:20:00 +01:00
parent fe2740c00e
commit b46d383200
9 changed files with 266 additions and 58 deletions

8
src/lib/debug.ts Normal file
View File

@@ -0,0 +1,8 @@
/** Dev-only logger — all output is stripped in production builds. */
const isDev = import.meta.env.DEV;
export const debug = {
log: (...args: unknown[]) => { if (isDev) console.log("[Wrystr]", ...args); },
warn: (...args: unknown[]) => { if (isDev) console.warn("[Wrystr]", ...args); },
error: (...args: unknown[]) => { if (isDev) console.error("[Wrystr]", ...args); },
};

View File

@@ -22,6 +22,45 @@ export async function fetchReactionCount(eventId: string): Promise<number> {
return events.size;
}
export interface GroupedReactions {
groups: Map<string, number>;
myReactions: Set<string>;
total: number;
}
/** Normalize reaction content: "+" and empty → "❤️", ignore "-" */
function normalizeEmoji(content: string): string | null {
if (content === "-") return null; // downvote — ignore
if (!content || content === "+") return "❤️";
return content;
}
/** Group reaction events by emoji. Pass myPubkey to track which emojis the user sent. */
export function groupReactions(events: Iterable<NDKEvent>, myPubkey?: string): GroupedReactions {
const groups = new Map<string, number>();
const myReactions = new Set<string>();
let total = 0;
for (const event of events) {
const emoji = normalizeEmoji(event.content);
if (!emoji) continue;
groups.set(emoji, (groups.get(emoji) ?? 0) + 1);
total++;
if (myPubkey && event.pubkey === myPubkey) {
myReactions.add(emoji);
}
}
return { groups, myReactions, total };
}
export async function fetchReactions(eventId: string, myPubkey?: string): Promise<GroupedReactions> {
const instance = getNDK();
const filter: NDKFilter = { kinds: [NDKKind.Reaction], "#e": [eventId] };
const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT);
return groupReactions(events, myPubkey);
}
export async function fetchReplyCount(eventId: string): Promise<number> {
const instance = getNDK();
const filter: NDKFilter = { kinds: [NDKKind.Text], "#e": [eventId] };
@@ -47,11 +86,19 @@ export async function fetchZapCount(eventId: string): Promise<{ count: number; t
return { count: events.size, totalSats };
}
export async function fetchBatchEngagement(eventIds: string[]): Promise<Map<string, { reactions: number; replies: number; zapSats: number }>> {
export interface BatchEngagement {
reactions: number;
replies: number;
zapSats: number;
reactionGroups: Map<string, number>;
myReactions: Set<string>;
}
export async function fetchBatchEngagement(eventIds: string[], myPubkey?: string): Promise<Map<string, BatchEngagement>> {
const instance = getNDK();
const result = new Map<string, { reactions: number; replies: number; zapSats: number }>();
const result = new Map<string, BatchEngagement>();
for (const id of eventIds) {
result.set(id, { reactions: 0, replies: 0, zapSats: 0 });
result.set(id, { reactions: 0, replies: 0, zapSats: 0, reactionGroups: new Map(), myReactions: new Set() });
}
// Batch in chunks to avoid oversized filters
@@ -65,13 +112,23 @@ export async function fetchBatchEngagement(eventIds: string[]): Promise<Map<stri
fetchWithTimeout(instance, { kinds: [NDKKind.Text], "#e": chunk }, FEED_TIMEOUT),
fetchWithTimeout(instance, { kinds: [NDKKind.Zap], "#e": chunk }, FEED_TIMEOUT),
]),
FEED_TIMEOUT + 2000, // outer timeout slightly longer than inner
FEED_TIMEOUT + 2000,
[new Set<NDKEvent>(), new Set<NDKEvent>(), new Set<NDKEvent>()],
);
for (const event of reactions) {
const eTag = event.tags.find((t) => t[0] === "e")?.[1];
if (eTag && result.has(eTag)) result.get(eTag)!.reactions++;
if (eTag && result.has(eTag)) {
const entry = result.get(eTag)!;
const emoji = normalizeEmoji(event.content);
if (emoji) {
entry.reactions++;
entry.reactionGroups.set(emoji, (entry.reactionGroups.get(emoji) ?? 0) + 1);
if (myPubkey && event.pubkey === myPubkey) {
entry.myReactions.add(emoji);
}
}
}
}
for (const event of replies) {

View File

@@ -2,7 +2,8 @@ export { getNDK, getNDKUptimeMs, connectToRelays, ensureConnected, resetNDK, get
export { fetchGlobalFeed, fetchFollowFeed, fetchUserNotes, fetchUserNotesNIP65, fetchNoteById, fetchReplies, publishNote, publishReply, publishRepost, publishQuote, fetchHashtagFeed, fetchThreadEvents, fetchAncestors } from "./notes";
export { publishProfile, publishContactList, fetchProfile, fetchFollowSuggestions, fetchMentions, fetchFollowers, fetchNewFollowers } from "./social";
export { publishArticle, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchByAddr } from "./articles";
export { publishReaction, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchBatchEngagement, fetchZapsReceived, fetchZapsSent } from "./engagement";
export { publishReaction, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchReactions, groupReactions, fetchBatchEngagement, fetchZapsReceived, fetchZapsSent } from "./engagement";
export type { GroupedReactions, BatchEngagement } from "./engagement";
export { fetchDMConversations, fetchDMThread, sendDM, decryptDM } from "./dms";
export { fetchBookmarkList, publishBookmarkList, fetchBookmarkListFull, publishBookmarkListFull } from "./bookmarks";
export { fetchMuteList, publishMuteList } from "./muting";

View File

@@ -1,6 +1,7 @@
import { fetchMentions, fetchZapsReceived, fetchNewFollowers, fetchProfile } from "./nostr";
import { fetchMentions, fetchZapsReceived, fetchNewFollowers, fetchProfile, ensureConnected } from "./nostr";
import { notifyMention, notifyZap, notifyFollower } from "./notifications";
import { useNotificationsStore } from "../stores/notifications";
import { debug } from "./debug";
const POLL_INTERVAL = 60_000; // 60 seconds
const POLL_TS_KEY = "wrystr_notif_poll_ts";
@@ -42,6 +43,15 @@ async function getProfileName(pubkey: string): Promise<string> {
}
async function pollOnce(pubkey: string) {
// Skip polling if no relays are connected — avoids empty results
try {
const connected = await ensureConnected();
if (!connected) {
debug.warn("notif:poll skipped — no relays connected");
return;
}
} catch { return; }
const ts = loadPollTimestamps();
const now = Math.floor(Date.now() / 1000);
@@ -109,10 +119,17 @@ async function pollOnce(pubkey: string) {
export function startNotificationPoller(pubkey: string) {
stopNotificationPoller();
// Fetch notification counts immediately (before full poll)
useNotificationsStore.getState().fetchNotifications(pubkey).catch(() => {});
// Run first full poll after a short delay (let relays connect)
setTimeout(() => pollOnce(pubkey).catch(() => {}), 5000);
// Wait for relay connection before first fetch — avoids empty results on startup
(async () => {
try {
const connected = await ensureConnected();
debug.log("notif:poller ensureConnected →", connected);
} catch { /* continue anyway */ }
debug.log("notif:poller initial fetch for", pubkey.slice(0, 8));
useNotificationsStore.getState().fetchNotifications(pubkey).catch(() => {});
})();
// Run first full poll after a longer delay (give relays more time)
setTimeout(() => pollOnce(pubkey).catch(() => {}), 8000);
intervalId = setInterval(() => pollOnce(pubkey).catch(() => {}), POLL_INTERVAL);
}