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

View File

@@ -1,7 +1,7 @@
import { useState } from "react";
import { NDKEvent, nip19 } from "@nostr-dev-kit/ndk";
import { useProfile } from "../../hooks/useProfile";
import { useReactionCount } from "../../hooks/useReactionCount";
import { useReactions } from "../../hooks/useReactions";
import { useReplyCount } from "../../hooks/useReplyCount";
import { useZapCount } from "../../hooks/useZapCount";
import { useUserStore } from "../../stores/user";
@@ -26,14 +26,8 @@ export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProp
const { bookmarkedIds, addBookmark, removeBookmark } = useBookmarkStore();
const isBookmarked = bookmarkedIds.includes(event.id!);
const likedKey = "wrystr_liked";
const getLiked = () => {
try { return new Set<string>(JSON.parse(localStorage.getItem(likedKey) || "[]")); }
catch { return new Set<string>(); }
};
const [liked, setLiked] = useState(() => getLiked().has(event.id));
const [liking, setLiking] = useState(false);
const [reactionCount, adjustReactionCount] = useReactionCount(event.id);
const [reactionsData, addReaction] = useReactions(event.id);
const [reacting, setReacting] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [replyCount] = useReplyCount(event.id);
const [copied, setCopied] = useState(false);
@@ -43,19 +37,17 @@ export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProp
const [reposting, setReposting] = useState(false);
const [reposted, setReposted] = useState(false);
const handleReact = async (emoji?: string) => {
if (!loggedIn || liked || liking) return;
setLiking(true);
const myReactions = reactionsData?.myReactions ?? new Set<string>();
const handleReact = async (emoji: string) => {
if (!loggedIn || reacting || myReactions.has(emoji)) return;
setReacting(true);
setShowEmojiPicker(false);
try {
await publishReaction(event.id, event.pubkey, emoji || "+");
const likedSet = getLiked();
likedSet.add(event.id);
localStorage.setItem(likedKey, JSON.stringify(Array.from(likedSet)));
setLiked(true);
adjustReactionCount(1);
await publishReaction(event.id, event.pubkey, emoji);
addReaction(emoji);
} finally {
setLiking(false);
setReacting(false);
}
};
@@ -77,9 +69,14 @@ export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProp
}
};
// Sort emoji groups: most popular first
const sortedGroups = reactionsData
? Array.from(reactionsData.groups.entries()).sort((a, b) => b[1] - a[1])
: [];
return (
<>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-2">
<button
onClick={onReplyToggle}
className={`text-[11px] transition-colors ${
@@ -88,25 +85,38 @@ export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProp
>
reply{replyCount !== null && replyCount > 0 ? ` ${replyCount}` : ""}
</button>
<div className="relative flex items-center gap-1">
<button
onClick={() => handleReact("❤️")}
disabled={liked || liking}
className={`text-[11px] transition-colors ${
liked ? "text-accent" : "text-text-dim hover:text-accent"
} disabled:cursor-default`}
>
{liked ? "♥" : "♡"}{reactionCount !== null && reactionCount > 0 ? ` ${reactionCount}` : liked ? " liked" : " like"}
</button>
{!liked && !liking && (
{/* Emoji reaction pills */}
<div className="relative flex flex-wrap items-center gap-1">
{sortedGroups.map(([emoji, count]) => (
<button
key={emoji}
onClick={() => handleReact(emoji)}
disabled={reacting || myReactions.has(emoji)}
className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[11px] rounded-sm border transition-colors ${
myReactions.has(emoji)
? "border-accent/40 bg-accent/10 text-accent"
: "border-border hover:border-accent/40 hover:bg-accent/5 text-text-dim"
} disabled:cursor-default`}
>
<span className="text-[13px] leading-none">{emoji}</span>
<span>{count}</span>
</button>
))}
{/* Add reaction button */}
{loggedIn && (
<button
onClick={() => setShowEmojiPicker((v) => !v)}
className="text-[10px] text-text-dim hover:text-accent transition-colors opacity-0 group-hover/card:opacity-100"
disabled={reacting}
className="inline-flex items-center px-1 py-0.5 text-[11px] text-text-dim hover:text-accent border border-transparent hover:border-border rounded-sm transition-colors opacity-0 group-hover/card:opacity-100 disabled:opacity-30"
title="React with emoji"
>
+
</button>
)}
{/* Emoji picker popover */}
{showEmojiPicker && (
<>
<div className="fixed inset-0 z-[9]" onClick={() => setShowEmojiPicker(false)} />
@@ -115,7 +125,10 @@ export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProp
<button
key={emoji}
onClick={() => handleReact(emoji)}
className="text-[16px] hover:scale-125 transition-transform px-0.5"
disabled={myReactions.has(emoji)}
className={`text-[16px] hover:scale-125 transition-transform px-0.5 ${
myReactions.has(emoji) ? "opacity-30 cursor-default" : ""
}`}
>
{emoji}
</button>
@@ -124,6 +137,7 @@ export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProp
</>
)}
</div>
<button
onClick={handleRepost}
disabled={reposting || reposted}
@@ -188,7 +202,7 @@ export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProp
}
export function LoggedOutStats({ event }: { event: NDKEvent }) {
const [reactionCount] = useReactionCount(event.id);
const [reactionsData] = useReactions(event.id);
const [replyCount] = useReplyCount(event.id);
const zapData = useZapCount(event.id);
const [copied, setCopied] = useState(false);
@@ -200,14 +214,21 @@ export function LoggedOutStats({ event }: { event: NDKEvent }) {
setTimeout(() => setCopied(false), 2000);
};
const sortedGroups = reactionsData
? Array.from(reactionsData.groups.entries()).sort((a, b) => b[1] - a[1])
: [];
return (
<div className="flex items-center gap-3 mt-1.5">
<div className="flex flex-wrap items-center gap-2 mt-1.5">
{replyCount !== null && replyCount > 0 && (
<span className="text-text-dim text-[11px]"> {replyCount}</span>
)}
{reactionCount !== null && reactionCount > 0 && (
<span className="text-text-dim text-[11px]"> {reactionCount}</span>
)}
{sortedGroups.map(([emoji, count]) => (
<span key={emoji} className="inline-flex items-center gap-0.5 text-text-dim text-[11px]">
<span className="text-[13px] leading-none">{emoji}</span>
<span>{count}</span>
</span>
))}
{zapData !== null && zapData.totalSats > 0 && (
<span className="text-zap text-[11px]"> {zapData.totalSats.toLocaleString()} sats</span>
)}

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { NDKEvent, nip19 } from "@nostr-dev-kit/ndk";
import { fetchFollowSuggestions, fetchProfile, advancedSearch, fetchTrendingHashtags } from "../../lib/nostr";
import { parseSearchQuery, describeSearch } from "../../lib/search";
import { useUserStore } from "../../stores/user";
@@ -202,6 +202,19 @@ export function SearchView() {
const q = (overrideQuery ?? query).trim();
if (!q) return;
if (overrideQuery) setQuery(overrideQuery);
// Bare npub/nprofile → navigate directly to profile
if (/^(npub1|nprofile1)[a-z0-9]+$/i.test(q)) {
try {
const decoded = nip19.decode(q);
const pubkey = decoded.type === "npub" ? decoded.data
: decoded.type === "nprofile" ? decoded.data.pubkey : null;
if (pubkey) {
useUIStore.getState().openProfile(pubkey);
return;
}
} catch { /* not valid, fall through to normal search */ }
}
setLoading(true);
setSearched(false);
setSearchHint(null);

89
src/hooks/useReactions.ts Normal file
View File

@@ -0,0 +1,89 @@
import { useEffect, useRef, useState } from "react";
import { fetchReactions } from "../lib/nostr";
import type { GroupedReactions } from "../lib/nostr";
import { useUserStore } from "../stores/user";
const cache = new Map<string, GroupedReactions>();
// Queue to throttle parallel relay queries — too many at once causes timeouts
const pending = new Map<string, Promise<GroupedReactions>>();
let activeCount = 0;
const MAX_CONCURRENT = 4;
const queue: Array<() => void> = [];
function runNext() {
if (queue.length > 0 && activeCount < MAX_CONCURRENT) {
const next = queue.shift()!;
next();
}
}
function throttledFetch(eventId: string, pubkey?: string): Promise<GroupedReactions> {
if (pending.has(eventId)) return pending.get(eventId)!;
const promise = new Promise<GroupedReactions>((resolve) => {
const doFetch = () => {
activeCount++;
fetchReactions(eventId, pubkey).then((result) => {
activeCount--;
pending.delete(eventId);
resolve(result);
runNext();
});
};
if (activeCount < MAX_CONCURRENT) {
doFetch();
} else {
queue.push(doFetch);
}
});
pending.set(eventId, promise);
return promise;
}
export function useReactions(eventId: string): [GroupedReactions | null, (emoji: string) => void] {
const [data, setData] = useState<GroupedReactions | null>(() => cache.get(eventId) ?? null);
const pubkeyRef = useRef(useUserStore.getState().pubkey);
useEffect(() => {
pubkeyRef.current = useUserStore.getState().pubkey;
});
useEffect(() => {
if (cache.has(eventId)) {
setData(cache.get(eventId)!);
return;
}
let cancelled = false;
throttledFetch(eventId, pubkeyRef.current ?? undefined).then((result) => {
if (!cancelled) {
cache.set(eventId, result);
setData(result);
}
});
return () => { cancelled = true; };
}, [eventId]);
const addReaction = (emoji: string) => {
setData((prev) => {
const groups = new Map(prev?.groups ?? []);
groups.set(emoji, (groups.get(emoji) ?? 0) + 1);
const myReactions = new Set(prev?.myReactions ?? []);
myReactions.add(emoji);
const total = (prev?.total ?? 0) + 1;
const next: GroupedReactions = { groups, myReactions, total };
cache.set(eventId, next);
return next;
});
};
return [data, addReaction];
}
/** Seed the cache from batch engagement data (avoids per-note refetching). */
export function seedReactionsCache(eventId: string, groups: Map<string, number>, myReactions: Set<string>) {
const total = Array.from(groups.values()).reduce((sum, n) => sum + n, 0);
cache.set(eventId, { groups, myReactions, total });
}

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

View File

@@ -48,9 +48,9 @@ describe("useFeedStore - loadTrendingFeed", () => {
];
const engagement = new Map([
["a", { reactions: 10, replies: 0, zapSats: 0 }], // score: 10
["b", { reactions: 0, replies: 5, zapSats: 0 }], // score: 15
["c", { reactions: 1, replies: 1, zapSats: 100 }], // score: 5
["a", { reactions: 10, replies: 0, zapSats: 0, reactionGroups: new Map<string, number>(), myReactions: new Set<string>() }], // score: 10
["b", { reactions: 0, replies: 5, zapSats: 0, reactionGroups: new Map<string, number>(), myReactions: new Set<string>() }], // score: 15
["c", { reactions: 1, replies: 1, zapSats: 100, reactionGroups: new Map<string, number>(), myReactions: new Set<string>() }], // score: 5
]);
vi.mocked(fetchTrendingCandidates).mockResolvedValue(notes);
@@ -73,8 +73,8 @@ describe("useFeedStore - loadTrendingFeed", () => {
];
const engagement = new Map([
["a", { reactions: 5, replies: 0, zapSats: 0 }],
["b", { reactions: 0, replies: 0, zapSats: 0 }],
["a", { reactions: 5, replies: 0, zapSats: 0, reactionGroups: new Map<string, number>(), myReactions: new Set<string>() }],
["b", { reactions: 0, replies: 0, zapSats: 0, reactionGroups: new Map<string, number>(), myReactions: new Set<string>() }],
]);
vi.mocked(fetchTrendingCandidates).mockResolvedValue(notes);
@@ -91,7 +91,7 @@ describe("useFeedStore - loadTrendingFeed", () => {
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 }])
notes.map((n) => [n.id, { reactions: 10, replies: 1, zapSats: 0, reactionGroups: new Map<string, number>(), myReactions: new Set<string>() }])
);
vi.mocked(fetchTrendingCandidates).mockResolvedValue(notes);

View File

@@ -1,6 +1,7 @@
import { create } from "zustand";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { fetchMentions } from "../lib/nostr";
import { debug } from "../lib/debug";
const NOTIF_READ_KEY = "wrystr_notif_read_ids";
const DM_SEEN_KEY = "wrystr_dm_last_seen";
@@ -71,18 +72,19 @@ export const useNotificationsStore = create<NotificationsState>((set, get) => ({
const since = Math.floor(Date.now() / 1000) - 7 * 86400;
// Fetch more than we need since we filter out own events
const events = await fetchMentions(pubkey, since, MAX_NOTIFICATIONS * 3);
// Filter out own events — your replies shouldn't be notifications
const others = events.filter((e) => e.pubkey !== pubkey);
const sorted = others.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)).slice(0, MAX_NOTIFICATIONS);
debug.log("notif:fetch", events.length, "raw →", others.length, "others →", sorted.length, "kept");
// Don't overwrite existing notifications with empty results (relay timeout/disconnect)
const { readIds, notifications: existing } = get();
if (sorted.length === 0 && existing.length > 0) {
// Keep existing notifications — relay probably timed out
debug.warn("notif:fetch empty result, keeping", existing.length, "existing");
return;
}
const unreadCount = sorted.filter((e) => !readIds.has(e.id!)).length;
debug.log("notif:set", sorted.length, "notifications,", unreadCount, "unread");
set({ notifications: sorted, unreadCount });
} catch {
// Non-critical — keep existing notifications on error