From 57630227e14342090cb31837dfe2841ec9def692 Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:09:11 +0100 Subject: [PATCH] Add NIP-05 badges, hashtag pages, keyword muting, suggestion dismissal, notification poller - Article cover: aspect-video replaces max-h-72 for consistent 16:9 - NIP-05 verification badge on note cards with 1-hour TTL cache - Dedicated hashtag feed pages (clicking #tag opens live feed, not search) - Keyword muting: word-boundary matching, applied across all feed views - Follow suggestion dismissal: persistent "don't suggest again" per person - Background notification poller (60s): mentions, zaps, new followers - All notification types independently toggleable in settings - Centralized notification firing (removed inline store notifications) --- src/App.tsx | 2 + src/components/article/ArticleView.tsx | 2 +- src/components/feed/Feed.tsx | 3 +- src/components/feed/HashtagFeed.tsx | 50 ++++++++ src/components/feed/NoteCard.tsx | 6 +- src/components/feed/NoteContent.tsx | 6 +- .../notifications/NotificationsView.tsx | 9 +- src/components/search/SearchView.tsx | 21 ++- src/components/shared/SettingsView.tsx | 74 ++++++++++- src/components/thread/ThreadView.tsx | 6 +- src/hooks/useNip05Verified.ts | 51 ++++++++ src/lib/nostr/client.ts | 31 +++++ src/lib/nostr/index.ts | 2 +- src/lib/notificationPoller.ts | 121 ++++++++++++++++++ src/lib/notifications.ts | 13 +- src/stores/dismissedSuggestions.ts | 50 ++++++++ src/stores/mute.ts | 58 ++++++++- src/stores/notifications.ts | 17 +-- src/stores/ui.ts | 6 +- src/stores/user.ts | 6 + 20 files changed, 499 insertions(+), 35 deletions(-) create mode 100644 src/components/feed/HashtagFeed.tsx create mode 100644 src/hooks/useNip05Verified.ts create mode 100644 src/lib/notificationPoller.ts create mode 100644 src/stores/dismissedSuggestions.ts diff --git a/src/App.tsx b/src/App.tsx index 85568c9..bee548d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { ZapHistoryView } from "./components/zap/ZapHistoryView"; import { DMView } from "./components/dm/DMView"; import { NotificationsView } from "./components/notifications/NotificationsView"; import { BookmarkView } from "./components/bookmark/BookmarkView"; +import { HashtagFeed } from "./components/feed/HashtagFeed"; import { HelpModal } from "./components/shared/HelpModal"; import { useUIStore } from "./stores/ui"; import { useUpdater } from "./hooks/useUpdater"; @@ -79,6 +80,7 @@ function App() { {currentView === "dm" && } {currentView === "notifications" && } {currentView === "bookmarks" && } + {currentView === "hashtag" && } {showHelp && } diff --git a/src/components/article/ArticleView.tsx b/src/components/article/ArticleView.tsx index ed2ebd2..6100dfd 100644 --- a/src/components/article/ArticleView.tsx +++ b/src/components/article/ArticleView.tsx @@ -276,7 +276,7 @@ export function ArticleView() { { (e.target as HTMLImageElement).style.display = "none"; }} /> diff --git a/src/components/feed/Feed.tsx b/src/components/feed/Feed.tsx index 4180a53..b0bf743 100644 --- a/src/components/feed/Feed.tsx +++ b/src/components/feed/Feed.tsx @@ -13,7 +13,7 @@ import { NDKEvent } from "@nostr-dev-kit/ndk"; export function Feed() { const { notes, loading, connected, error, connect, loadCachedFeed, loadFeed, trendingNotes, trendingLoading, loadTrendingFeed, focusedNoteIndex } = useFeedStore(); const { loggedIn, follows } = useUserStore(); - const { mutedPubkeys } = useMuteStore(); + const { mutedPubkeys, contentMatchesMutedKeyword } = useMuteStore(); const { feedTab: tab, setFeedTab: setTab, feedLanguageFilter, setFeedLanguageFilter } = useUIStore(); const [followNotes, setFollowNotes] = useState([]); const [followLoading, setFollowLoading] = useState(false); @@ -51,6 +51,7 @@ export function Feed() { const filteredNotes = activeNotes.filter((event) => { if (mutedPubkeys.includes(event.pubkey)) return false; + if (contentMatchesMutedKeyword(event.content)) return false; const c = event.content.trim(); if (!c || c.startsWith("{") || c.startsWith("[")) return false; // Filter out notes that look like base64 blobs or relay protocol messages diff --git a/src/components/feed/HashtagFeed.tsx b/src/components/feed/HashtagFeed.tsx new file mode 100644 index 0000000..2eeb6bd --- /dev/null +++ b/src/components/feed/HashtagFeed.tsx @@ -0,0 +1,50 @@ +import { useState, useEffect } from "react"; +import { NDKEvent } from "@nostr-dev-kit/ndk"; +import { useUIStore } from "../../stores/ui"; +import { fetchHashtagFeed } from "../../lib/nostr"; +import { NoteCard } from "./NoteCard"; + +export function HashtagFeed() { + const { pendingHashtag, goBack } = useUIStore(); + const tag = pendingHashtag ?? ""; + + const [notes, setNotes] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!tag) return; + setLoading(true); + setNotes([]); + fetchHashtagFeed(tag) + .then(setNotes) + .finally(() => setLoading(false)); + }, [tag]); + + return ( +
+
+ +

#{tag}

+ {!loading && ( + {notes.length} note{notes.length !== 1 ? "s" : ""} + )} +
+ +
+ {loading && ( +
Loading notes for #{tag}…
+ )} + + {!loading && notes.length === 0 && ( +
No notes found for #{tag}
+ )} + + {notes.map((event) => ( + + ))} +
+
+ ); +} diff --git a/src/components/feed/NoteCard.tsx b/src/components/feed/NoteCard.tsx index 6479f7c..30ad0ac 100644 --- a/src/components/feed/NoteCard.tsx +++ b/src/components/feed/NoteCard.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useEffect } from "react"; import { NDKEvent } from "@nostr-dev-kit/ndk"; import { useProfile } from "../../hooks/useProfile"; +import { useNip05Verified } from "../../hooks/useNip05Verified"; import { useReactionCount } from "../../hooks/useReactionCount"; import { useReplyCount } from "../../hooks/useReplyCount"; import { useZapCount } from "../../hooks/useZapCount"; @@ -39,6 +40,7 @@ export function NoteCard({ event, focused }: NoteCardProps) { const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey); const avatar = profile?.picture; const nip05 = profile?.nip05; + const verified = useNip05Verified(event.pubkey, nip05); const time = event.created_at ? timeAgo(event.created_at) : ""; const { loggedIn, pubkey: ownPubkey, follows, follow, unfollow } = useUserStore(); @@ -175,7 +177,9 @@ export function NoteCard({ event, focused }: NoteCardProps) { onClick={() => openProfile(event.pubkey)} >{name} {nip05 && ( - {nip05} + + {verified === "valid" ? "✓ " : ""}{nip05} + )} {time} {/* Context menu — hidden until card hover, not shown for own notes */} diff --git a/src/components/feed/NoteContent.tsx b/src/components/feed/NoteContent.tsx index 44db418..9a2d15e 100644 --- a/src/components/feed/NoteContent.tsx +++ b/src/components/feed/NoteContent.tsx @@ -194,7 +194,7 @@ interface NoteContentProps { } export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) { - const { openSearch } = useUIStore(); + const { openHashtag } = useUIStore(); const segments = parseContent(content); const images: string[] = segments.filter((s) => s.type === "image").map((s) => s.value); const videos: string[] = segments.filter((s) => s.type === "video").map((s) => s.value); @@ -246,7 +246,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) { { e.stopPropagation(); openSearch(`#${seg.value}`); }} + onClick={(e) => { e.stopPropagation(); openHashtag(seg.value); }} > {seg.display} @@ -441,7 +441,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) { { e.stopPropagation(); openSearch(`#${seg.value}`); }} + onClick={(e) => { e.stopPropagation(); openHashtag(seg.value); }} > {seg.display} diff --git a/src/components/notifications/NotificationsView.tsx b/src/components/notifications/NotificationsView.tsx index 5527c46..dbb9abf 100644 --- a/src/components/notifications/NotificationsView.tsx +++ b/src/components/notifications/NotificationsView.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from "react"; import { useUserStore } from "../../stores/user"; +import { useMuteStore } from "../../stores/mute"; import { useNotificationsStore } from "../../stores/notifications"; import { NoteCard } from "../feed/NoteCard"; import { SkeletonNoteList } from "../shared/Skeleton"; @@ -14,6 +15,10 @@ export function NotificationsView() { fetchNotifications, markAllRead, } = useNotificationsStore(); + const { mutedPubkeys, contentMatchesMutedKeyword } = useMuteStore(); + const filteredNotifications = notifications.filter( + (e) => !mutedPubkeys.includes(e.pubkey) && !contentMatchesMutedKeyword(e.content) + ); // Capture lastSeenAt at mount time so unread highlights persist during this view session const prevLastSeenAtRef = useRef(lastSeenAt); @@ -52,14 +57,14 @@ export function NotificationsView() { )} - {!loading && notifications.length === 0 && ( + {!loading && filteredNotifications.length === 0 && (

No mentions yet.

When someone mentions you, it will appear here.

)} - {notifications.map((event) => { + {filteredNotifications.map((event) => { const isUnread = (event.created_at ?? 0) > prevLastSeenAtRef.current; return (
!dismissedPubkeys.includes(s.pubkey) && !mutedPubkeys.includes(s.pubkey) && !follows.includes(s.pubkey) + ); // Load follow suggestions on mount (only for logged-in users with follows) useEffect(() => { @@ -384,8 +392,8 @@ export function SearchView() { {suggestionsLoading && (
Finding suggestions...
)} - {suggestions.map((s) => s.profile && ( -
+ {visibleSuggestions.map((s) => s.profile && ( +
useUIStore.getState().openProfile(s.pubkey)}> {s.profile.picture ? ( +
))} - {suggestionsLoaded && suggestions.length === 0 && ( + {suggestionsLoaded && visibleSuggestions.length === 0 && (
Follow more people to see suggestions here.
diff --git a/src/components/shared/SettingsView.tsx b/src/components/shared/SettingsView.tsx index da67c2f..5a20fc9 100644 --- a/src/components/shared/SettingsView.tsx +++ b/src/components/shared/SettingsView.tsx @@ -45,6 +45,74 @@ function MuteSection() { ); } +function MutedKeywordsSection() { + const { mutedKeywords, addKeyword, removeKeyword } = useMuteStore(); + const [input, setInput] = useState(""); + const [error, setError] = useState(null); + + const handleAdd = () => { + const trimmed = input.trim().toLowerCase(); + if (trimmed.length < 2) { + setError("Minimum 2 characters"); + return; + } + if (mutedKeywords.includes(trimmed)) { + setError("Already muted"); + return; + } + addKeyword(trimmed); + setInput(""); + setError(null); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") handleAdd(); + if (e.key === "Escape") setInput(""); + }; + + return ( +
+

+ Muted keywords {mutedKeywords.length > 0 && `(${mutedKeywords.length})`} +

+

+ Notes containing these words or phrases will be hidden from your feeds. +

+ {mutedKeywords.length > 0 && ( +
+ {mutedKeywords.map((kw) => ( +
+ {kw} + +
+ ))} +
+ )} +
+ { setInput(e.target.value); setError(null); }} + onKeyDown={handleKeyDown} + placeholder="word or phrase to mute" + className="flex-1 bg-bg border border-border px-3 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50 placeholder:text-text-dim" + /> + +
+ {error &&

{error}

} +
+ ); +} + function RelayRow({ url, onRemove }: { url: string; onRemove: () => void }) { const ndk = getNDK(); const relay = ndk.pool?.relays.get(url); @@ -267,7 +335,7 @@ function ExportSection() { function NotificationSection() { const [settings, setSettings] = useState(getNotificationSettings); - const toggle = (key: "mentions" | "dms" | "zaps") => { + const toggle = (key: "mentions" | "dms" | "zaps" | "followers") => { const next = { ...settings, [key]: !settings[key] }; setSettings(next); saveNotificationSettings(next); @@ -275,10 +343,11 @@ function NotificationSection() { if (next[key]) ensurePermission().catch(() => {}); }; - const items: Array<{ key: "mentions" | "dms" | "zaps"; label: string }> = [ + const items: Array<{ key: "mentions" | "dms" | "zaps" | "followers"; label: string }> = [ { key: "mentions", label: "Mentions" }, { key: "dms", label: "Direct messages" }, { key: "zaps", label: "Zaps received" }, + { key: "followers", label: "New followers" }, ]; return ( @@ -326,6 +395,7 @@ export function SettingsView() { +
); diff --git a/src/components/thread/ThreadView.tsx b/src/components/thread/ThreadView.tsx index a7ce24c..94d9f1e 100644 --- a/src/components/thread/ThreadView.tsx +++ b/src/components/thread/ThreadView.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { NDKEvent } from "@nostr-dev-kit/ndk"; import { useUIStore } from "../../stores/ui"; import { useUserStore } from "../../stores/user"; +import { useMuteStore } from "../../stores/mute"; import { useProfile } from "../../hooks/useProfile"; import { useReactionCount } from "../../hooks/useReactionCount"; import { useZapCount } from "../../hooks/useZapCount"; @@ -145,6 +146,7 @@ function RootNote({ event }: { event: NDKEvent }) { export function ThreadView() { const { selectedNote, goBack } = useUIStore(); const { loggedIn } = useUserStore(); + const { mutedPubkeys, contentMatchesMutedKeyword } = useMuteStore(); if (!selectedNote) { goBack(); return null; } const event = selectedNote; @@ -239,7 +241,9 @@ export function ThreadView() {
)} - {replies.map((reply) => ( + {replies + .filter((r) => !mutedPubkeys.includes(r.pubkey) && !contentMatchesMutedKeyword(r.content)) + .map((reply) => ( ))} diff --git a/src/hooks/useNip05Verified.ts b/src/hooks/useNip05Verified.ts new file mode 100644 index 0000000..8e15cd0 --- /dev/null +++ b/src/hooks/useNip05Verified.ts @@ -0,0 +1,51 @@ +import { useState, useEffect } from "react"; + +type VerifyStatus = "valid" | "invalid"; + +const cache = new Map(); +const TTL = 3600000; // 1 hour + +async function verifyNip05(pubkey: string, nip05: string): Promise { + const parts = nip05.split("@"); + if (parts.length !== 2) return "invalid"; + const [name, domain] = parts; + try { + const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`); + if (!res.ok) return "invalid"; + const json = await res.json(); + const resolved = json?.names?.[name]; + return resolved === pubkey ? "valid" : "invalid"; + } catch { + return "invalid"; + } +} + +export function useNip05Verified(pubkey: string, nip05: string | undefined): "valid" | "invalid" | "checking" | null { + const [status, setStatus] = useState<"valid" | "invalid" | "checking" | null>(() => { + if (!nip05) return null; + const cached = cache.get(pubkey); + if (cached && Date.now() - cached.checkedAt < TTL) return cached.status; + return "checking"; + }); + + useEffect(() => { + if (!nip05) { setStatus(null); return; } + + const cached = cache.get(pubkey); + if (cached && Date.now() - cached.checkedAt < TTL) { + setStatus(cached.status); + return; + } + + let cancelled = false; + setStatus("checking"); + verifyNip05(pubkey, nip05).then((result) => { + if (cancelled) return; + cache.set(pubkey, { status: result, checkedAt: Date.now() }); + setStatus(result); + }); + return () => { cancelled = true; }; + }, [pubkey, nip05]); + + return status; +} diff --git a/src/lib/nostr/client.ts b/src/lib/nostr/client.ts index 9d6ffda..7fb2429 100644 --- a/src/lib/nostr/client.ts +++ b/src/lib/nostr/client.ts @@ -867,6 +867,37 @@ export async function fetchTrendingHashtags(limit = 15): Promise<{ tag: string; .slice(0, limit); } +// ── New Followers ───────────────────────────────────────────────────────────── + +export async function fetchNewFollowers(pubkey: string, since: number, limit = 20): Promise { + const instance = getNDK(); + const filter: NDKFilter = { + kinds: [3 as NDKKind], + "#p": [pubkey], + since, + limit, + }; + const events = await instance.fetchEvents(filter, { + cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, + }); + return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); +} + +// ── Hashtag Feed ────────────────────────────────────────────────────────────── + +export async function fetchHashtagFeed(tag: string, limit = 100): Promise { + const instance = getNDK(); + const filter: NDKFilter = { + kinds: [NDKKind.Text], + "#t": [tag.toLowerCase()], + limit, + }; + const events = await instance.fetchEvents(filter, { + cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, + }); + return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); +} + // ── Advanced Search ─────────────────────────────────────────────────────────── export interface AdvancedSearchResults { diff --git a/src/lib/nostr/index.ts b/src/lib/nostr/index.ts index 188ecfe..56c8ac9 100644 --- a/src/lib/nostr/index.ts +++ b/src/lib/nostr/index.ts @@ -1,2 +1,2 @@ -export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchBatchEngagement, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchBookmarkListFull, publishBookmarkListFull, fetchByAddr, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions, resolveNip05, advancedSearch, fetchRelayRecommendations, fetchTrendingHashtags } from "./client"; +export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchBatchEngagement, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchBookmarkListFull, publishBookmarkListFull, fetchByAddr, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions, resolveNip05, advancedSearch, fetchRelayRecommendations, fetchTrendingHashtags, fetchHashtagFeed, fetchNewFollowers } from "./client"; export type { UserRelayList, AdvancedSearchResults } from "./client"; diff --git a/src/lib/notificationPoller.ts b/src/lib/notificationPoller.ts new file mode 100644 index 0000000..74e9063 --- /dev/null +++ b/src/lib/notificationPoller.ts @@ -0,0 +1,121 @@ +import { fetchMentions, fetchZapsReceived, fetchNewFollowers, fetchProfile } from "./nostr"; +import { notifyMention, notifyZap, notifyFollower } from "./notifications"; +import { useNotificationsStore } from "../stores/notifications"; + +const POLL_INTERVAL = 60_000; // 60 seconds +const POLL_TS_KEY = "wrystr_notif_poll_ts"; +const MAX_SEEN = 200; + +let intervalId: ReturnType | null = null; +const recentlySeen = new Set(); + +function loadPollTimestamps(): Record { + try { + return JSON.parse(localStorage.getItem(POLL_TS_KEY) ?? "{}"); + } catch { + return {}; + } +} + +function savePollTimestamps(ts: Record) { + localStorage.setItem(POLL_TS_KEY, JSON.stringify(ts)); +} + +function trimSeenSet() { + if (recentlySeen.size > MAX_SEEN) { + const arr = Array.from(recentlySeen); + arr.splice(0, arr.length - MAX_SEEN); + recentlySeen.clear(); + arr.forEach((id) => recentlySeen.add(id)); + } +} + +async function getProfileName(pubkey: string): Promise { + try { + const p = await fetchProfile(pubkey); + if (p) { + const meta = p as Record; + return meta.display_name || meta.name || pubkey.slice(0, 8) + "…"; + } + } catch { /* ignore */ } + return pubkey.slice(0, 8) + "…"; +} + +async function pollOnce(pubkey: string) { + const ts = loadPollTimestamps(); + const now = Math.floor(Date.now() / 1000); + + // Mentions + try { + const mentionsSince = ts.mentions || (now - 300); + const mentions = await fetchMentions(pubkey, mentionsSince, 10); + for (const e of mentions) { + if (recentlySeen.has(e.id!)) continue; + if (e.pubkey === pubkey) continue; // don't notify self-mentions + recentlySeen.add(e.id!); + const name = await getProfileName(e.pubkey); + notifyMention(name, e.content?.slice(0, 120) || "mentioned you").catch(() => {}); + } + if (mentions.length > 0) ts.mentions = now; + // Also update the notifications store unread count + useNotificationsStore.getState().fetchNotifications(pubkey).catch(() => {}); + } catch { /* non-critical */ } + + // Zaps + try { + const zapsSince = ts.zaps || (now - 300); + const zaps = await fetchZapsReceived(pubkey, 10); + for (const e of zaps) { + if (recentlySeen.has(e.id!)) continue; + if ((e.created_at ?? 0) <= zapsSince) continue; + recentlySeen.add(e.id!); + // Extract sender and amount from zap receipt + const desc = e.tags.find((t) => t[0] === "description")?.[1]; + let senderName = "Someone"; + let amount = 0; + if (desc) { + try { + const zapReq = JSON.parse(desc) as { pubkey?: string; tags?: string[][] }; + if (zapReq.pubkey) senderName = await getProfileName(zapReq.pubkey); + const amountTag = zapReq.tags?.find((t) => t[0] === "amount"); + if (amountTag?.[1]) amount = Math.round(parseInt(amountTag[1]) / 1000); + } catch { /* malformed */ } + } + if (amount > 0) { + notifyZap(senderName, amount).catch(() => {}); + } + } + ts.zaps = now; + } catch { /* non-critical */ } + + // New followers + try { + const followersSince = ts.followers || (now - 300); + const followers = await fetchNewFollowers(pubkey, followersSince, 5); + for (const e of followers) { + if (recentlySeen.has(e.id!)) continue; + if (e.pubkey === pubkey) continue; + recentlySeen.add(e.id!); + const name = await getProfileName(e.pubkey); + notifyFollower(name).catch(() => {}); + } + if (followers.length > 0) ts.followers = now; + } catch { /* non-critical */ } + + trimSeenSet(); + savePollTimestamps(ts); +} + +export function startNotificationPoller(pubkey: string) { + stopNotificationPoller(); + // Run first poll after a short delay (let relays connect) + setTimeout(() => pollOnce(pubkey).catch(() => {}), 5000); + intervalId = setInterval(() => pollOnce(pubkey).catch(() => {}), POLL_INTERVAL); +} + +export function stopNotificationPoller() { + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } +} diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 4057add..36b6149 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -10,9 +10,10 @@ interface NotificationSettings { mentions: boolean; dms: boolean; zaps: boolean; + followers: boolean; } -const defaults: NotificationSettings = { mentions: true, dms: true, zaps: true }; +const defaults: NotificationSettings = { mentions: true, dms: true, zaps: true, followers: true }; export function getNotificationSettings(): NotificationSettings { try { @@ -65,3 +66,13 @@ export async function notifyZap(senderName: string, amount: number): Promise { + const settings = getNotificationSettings(); + if (!settings.followers) return; + if (!(await ensurePermission())) return; + sendNotification({ + title: `${followerName} followed you`, + body: "You have a new follower", + }); +} diff --git a/src/stores/dismissedSuggestions.ts b/src/stores/dismissedSuggestions.ts new file mode 100644 index 0000000..f06b628 --- /dev/null +++ b/src/stores/dismissedSuggestions.ts @@ -0,0 +1,50 @@ +import { create } from "zustand"; + +const STORAGE_KEY = "wrystr_dismissed_suggestions"; + +function loadDismissed(): string[] { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); + } catch { + return []; + } +} + +function saveDismissed(pubkeys: string[]) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(pubkeys)); +} + +interface DismissedSuggestionsState { + dismissedPubkeys: string[]; + dismiss: (pubkey: string) => void; + undismiss: (pubkey: string) => void; + isDismissed: (pubkey: string) => boolean; + clearAll: () => void; +} + +export const useDismissedSuggestionsStore = create((set, get) => ({ + dismissedPubkeys: loadDismissed(), + + dismiss: (pubkey: string) => { + const { dismissedPubkeys } = get(); + if (dismissedPubkeys.includes(pubkey)) return; + const updated = [...dismissedPubkeys, pubkey]; + set({ dismissedPubkeys: updated }); + saveDismissed(updated); + }, + + undismiss: (pubkey: string) => { + const updated = get().dismissedPubkeys.filter((p) => p !== pubkey); + set({ dismissedPubkeys: updated }); + saveDismissed(updated); + }, + + isDismissed: (pubkey: string) => { + return get().dismissedPubkeys.includes(pubkey); + }, + + clearAll: () => { + set({ dismissedPubkeys: [] }); + saveDismissed([]); + }, +})); diff --git a/src/stores/mute.ts b/src/stores/mute.ts index c4beab4..a483fb9 100644 --- a/src/stores/mute.ts +++ b/src/stores/mute.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { fetchMuteList, publishMuteList } from "../lib/nostr"; const STORAGE_KEY = "wrystr_mutes"; +const KEYWORDS_KEY = "wrystr_muted_keywords"; function loadLocal(): string[] { try { @@ -15,21 +16,51 @@ function saveLocal(pubkeys: string[]) { localStorage.setItem(STORAGE_KEY, JSON.stringify(pubkeys)); } +function loadKeywords(): string[] { + try { + return JSON.parse(localStorage.getItem(KEYWORDS_KEY) ?? "[]"); + } catch { + return []; + } +} + +function saveKeywords(keywords: string[]) { + localStorage.setItem(KEYWORDS_KEY, JSON.stringify(keywords)); +} + +// Build word-boundary regexes for single words, substring match for phrases +function buildKeywordMatchers(keywords: string[]): Array<(content: string) => boolean> { + return keywords.map((kw) => { + const lower = kw.toLowerCase(); + if (/\s/.test(lower)) { + // Phrase — substring match + return (content: string) => content.toLowerCase().includes(lower); + } + // Single word — word boundary match + const re = new RegExp(`\\b${lower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i"); + return (content: string) => re.test(content); + }); +} + interface MuteState { mutedPubkeys: string[]; + mutedKeywords: string[]; fetchMuteList: (pubkey: string) => Promise; mute: (pubkey: string) => Promise; unmute: (pubkey: string) => Promise; + addKeyword: (keyword: string) => void; + removeKeyword: (keyword: string) => void; + contentMatchesMutedKeyword: (content: string) => boolean; } export const useMuteStore = create((set, get) => ({ mutedPubkeys: loadLocal(), + mutedKeywords: loadKeywords(), fetchMuteList: async (pubkey: string) => { try { const pubkeys = await fetchMuteList(pubkey); if (pubkeys.length === 0) return; - // Merge relay list with any local-only mutes (e.g. from npub sessions) const local = get().mutedPubkeys; const merged = Array.from(new Set([...pubkeys, ...local])); set({ mutedPubkeys: merged }); @@ -45,7 +76,7 @@ export const useMuteStore = create((set, get) => ({ const updated = [...mutedPubkeys, pubkey]; set({ mutedPubkeys: updated }); saveLocal(updated); - publishMuteList(updated).catch(() => {}); // best-effort relay publish + publishMuteList(updated).catch(() => {}); }, unmute: async (pubkey: string) => { @@ -54,4 +85,27 @@ export const useMuteStore = create((set, get) => ({ saveLocal(updated); publishMuteList(updated).catch(() => {}); }, + + addKeyword: (keyword: string) => { + const trimmed = keyword.trim().toLowerCase(); + if (trimmed.length < 2) return; + const { mutedKeywords } = get(); + if (mutedKeywords.includes(trimmed)) return; + const updated = [...mutedKeywords, trimmed]; + set({ mutedKeywords: updated }); + saveKeywords(updated); + }, + + removeKeyword: (keyword: string) => { + const updated = get().mutedKeywords.filter((k) => k !== keyword); + set({ mutedKeywords: updated }); + saveKeywords(updated); + }, + + contentMatchesMutedKeyword: (content: string) => { + const { mutedKeywords } = get(); + if (mutedKeywords.length === 0) return false; + const matchers = buildKeywordMatchers(mutedKeywords); + return matchers.some((match) => match(content)); + }, })); diff --git a/src/stores/notifications.ts b/src/stores/notifications.ts index 58069fc..6e252f7 100644 --- a/src/stores/notifications.ts +++ b/src/stores/notifications.ts @@ -1,7 +1,6 @@ import { create } from "zustand"; import { NDKEvent } from "@nostr-dev-kit/ndk"; import { fetchMentions } from "../lib/nostr"; -import { notifyMention, notifyDM } from "../lib/notifications"; const NOTIF_SEEN_KEY = "wrystr_notif_last_seen"; const DM_SEEN_KEY = "wrystr_dm_last_seen"; @@ -55,14 +54,6 @@ export const useNotificationsStore = create((set, get) => ({ const events = await fetchMentions(pubkey, lastSeenAt); const newEvents = events.filter((e) => (e.created_at ?? 0) > lastSeenAt); const unreadCount = newEvents.length; - // Fire OS notification for new mentions (only truly new ones since last fetch) - const prevCount = get().unreadCount; - if (unreadCount > prevCount && newEvents.length > 0) { - const latest = newEvents[0]; - const authorName = latest.pubkey.slice(0, 8) + "…"; - const preview = latest.content?.slice(0, 120) || "mentioned you"; - notifyMention(authorName, preview).catch(() => {}); - } set({ notifications: events, unreadCount, lastSeenAt }); } catch { // Non-critical @@ -86,17 +77,11 @@ export const useNotificationsStore = create((set, get) => ({ }, computeDMUnread: (conversations: Array<{ partnerPubkey: string; lastAt: number }>) => { - const { dmLastSeen, dmUnreadCount: prevCount } = get(); + const { dmLastSeen } = get(); const unreadConvos = conversations.filter( (c) => c.lastAt > (dmLastSeen[c.partnerPubkey] ?? 0) ); const dmUnreadCount = unreadConvos.length; - // Fire OS notification if new unread DMs appeared - if (dmUnreadCount > prevCount && unreadConvos.length > 0) { - const latest = unreadConvos[0]; - const name = latest.partnerPubkey.slice(0, 8) + "…"; - notifyDM(name, "New message").catch(() => {}); - } set({ dmUnreadCount }); }, })); diff --git a/src/stores/ui.ts b/src/stores/ui.ts index 03dcd15..5591923 100644 --- a/src/stores/ui.ts +++ b/src/stores/ui.ts @@ -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"; +type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "articles" | "about" | "zaps" | "dm" | "notifications" | "bookmarks" | "hashtag"; type FeedTab = "global" | "following" | "trending"; interface UIState { @@ -16,6 +16,7 @@ interface UIState { pendingDMPubkey: string | null; pendingArticleNaddr: string | null; pendingArticleEvent: NDKEvent | null; + pendingHashtag: string | null; showHelp: boolean; feedLanguageFilter: string | null; setView: (view: View) => void; @@ -23,6 +24,7 @@ interface UIState { openProfile: (pubkey: string) => void; openThread: (note: NDKEvent, from: View) => void; openSearch: (query: string) => void; + openHashtag: (tag: string) => void; openDM: (pubkey: string) => void; openArticle: (naddr: string, event?: NDKEvent) => void; goBack: () => void; @@ -44,6 +46,7 @@ export const useUIStore = create((set, _get) => ({ pendingDMPubkey: null, pendingArticleNaddr: null, pendingArticleEvent: null, + pendingHashtag: null, showHelp: false, feedLanguageFilter: null, setView: (currentView) => set({ currentView }), @@ -51,6 +54,7 @@ export const useUIStore = create((set, _get) => ({ openProfile: (pubkey) => set((s) => ({ currentView: "profile", selectedPubkey: pubkey, previousView: s.currentView as View })), openThread: (note, from) => set({ currentView: "thread", selectedNote: note, previousView: from }), openSearch: (query) => set({ currentView: "search", pendingSearch: query }), + openHashtag: (tag) => set((s) => ({ currentView: "hashtag", pendingHashtag: tag, previousView: s.currentView as View })), openDM: (pubkey) => set({ currentView: "dm", pendingDMPubkey: pubkey }), openArticle: (naddr, event) => set((s) => ({ currentView: "article", pendingArticleNaddr: naddr, pendingArticleEvent: event ?? null, previousView: s.currentView as View })), goBack: () => set((s) => ({ diff --git a/src/stores/user.ts b/src/stores/user.ts index 918c0b7..1aa7838 100644 --- a/src/stores/user.ts +++ b/src/stores/user.ts @@ -8,6 +8,7 @@ import { useLightningStore } from "./lightning"; import { useUIStore } from "./ui"; import { useNotificationsStore } from "./notifications"; import { useFeedStore } from "./feed"; +import { startNotificationPoller, stopNotificationPoller } from "../lib/notificationPoller"; export interface SavedAccount { pubkey: string; @@ -122,6 +123,7 @@ export const useUserStore = create((set, get) => ({ get().fetchFollows(); useMuteStore.getState().fetchMuteList(pubkey); useNotificationsStore.getState().fetchNotifications(pubkey); + startNotificationPoller(pubkey); // Navigate to feed and refresh so the new account's content loads useUIStore.getState().setView("feed"); @@ -165,6 +167,7 @@ export const useUserStore = create((set, get) => ({ get().fetchFollows(); useMuteStore.getState().fetchMuteList(pubkey); useNotificationsStore.getState().fetchNotifications(pubkey); + startNotificationPoller(pubkey); useUIStore.getState().setView("feed"); useFeedStore.getState().loadFeed(); @@ -174,6 +177,7 @@ export const useUserStore = create((set, get) => ({ }, logout: () => { + stopNotificationPoller(); const ndk = getNDK(); ndk.signer = undefined; // Don't delete the keychain entry — keep the account available for instant switch-back. @@ -228,6 +232,7 @@ export const useUserStore = create((set, get) => ({ get().fetchFollows(); useMuteStore.getState().fetchMuteList(savedPubkey); useNotificationsStore.getState().fetchNotifications(savedPubkey); + startNotificationPoller(savedPubkey); } // No keychain entry → stay logged out, user re-enters nsec once. } @@ -252,6 +257,7 @@ export const useUserStore = create((set, get) => ({ get().fetchOwnProfile(); get().fetchFollows(); useMuteStore.getState().fetchMuteList(pubkey); + startNotificationPoller(pubkey); useUIStore.getState().setView("feed"); return; }