diff --git a/src/components/feed/Feed.tsx b/src/components/feed/Feed.tsx index 48ada85..cf417ae 100644 --- a/src/components/feed/Feed.tsx +++ b/src/components/feed/Feed.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { useFeedStore } from "../../stores/feed"; import { useUserStore } from "../../stores/user"; +import { useMuteStore } from "../../stores/mute"; import { fetchFollowFeed, getNDK } from "../../lib/nostr"; import { NoteCard } from "./NoteCard"; import { ComposeBox } from "./ComposeBox"; @@ -11,6 +12,7 @@ type FeedTab = "global" | "following"; export function Feed() { const { notes, loading, connected, error, connect, loadCachedFeed, loadFeed } = useFeedStore(); const { loggedIn, follows } = useUserStore(); + const { mutedPubkeys } = useMuteStore(); const [tab, setTab] = useState("global"); const [followNotes, setFollowNotes] = useState([]); @@ -43,6 +45,7 @@ export function Feed() { const isLoading = isFollowing ? followLoading : loading; const filteredNotes = activeNotes.filter((event) => { + if (mutedPubkeys.includes(event.pubkey)) 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/NoteCard.tsx b/src/components/feed/NoteCard.tsx index fe5a1bc..07832f0 100644 --- a/src/components/feed/NoteCard.tsx +++ b/src/components/feed/NoteCard.tsx @@ -3,6 +3,7 @@ import { NDKEvent } from "@nostr-dev-kit/ndk"; import { useProfile } from "../../hooks/useProfile"; import { useReactionCount } from "../../hooks/useReactionCount"; import { useUserStore } from "../../stores/user"; +import { useMuteStore } from "../../stores/mute"; import { useUIStore } from "../../stores/ui"; import { timeAgo, shortenPubkey } from "../../lib/utils"; import { publishReaction, publishReply, getNDK } from "../../lib/nostr"; @@ -20,7 +21,9 @@ export function NoteCard({ event }: NoteCardProps) { const nip05 = profile?.nip05; const time = event.created_at ? timeAgo(event.created_at) : ""; - const { loggedIn } = useUserStore(); + const { loggedIn, pubkey: ownPubkey } = useUserStore(); + const { mutedPubkeys, mute, unmute } = useMuteStore(); + const isMuted = mutedPubkeys.includes(event.pubkey); const { openProfile, openThread, currentView } = useUIStore(); const likedKey = "wrystr_liked"; const getLiked = () => { @@ -37,6 +40,7 @@ export function NoteCard({ event }: NoteCardProps) { const [replySent, setReplySent] = useState(false); const replyRef = useRef(null); const [showZap, setShowZap] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); const handleLike = async () => { if (!loggedIn || liked || liking) return; @@ -80,7 +84,7 @@ export function NoteCard({ event }: NoteCardProps) { }; return ( -
+
{/* Avatar */}
openProfile(event.pubkey)}> @@ -112,6 +116,30 @@ export function NoteCard({ event }: NoteCardProps) { {nip05} )} {time} + {/* Context menu — hidden until card hover, not shown for own notes */} + {loggedIn && event.pubkey !== ownPubkey && ( +
+ + {menuOpen && ( + <> +
setMenuOpen(false)} /> +
+ +
+ + )} +
+ )}
{ setFollowPending(true); @@ -185,6 +188,16 @@ export function ProfileView() { > {followPending ? "…" : isFollowing ? "unfollow" : "follow"} +
)} diff --git a/src/components/shared/SettingsView.tsx b/src/components/shared/SettingsView.tsx index f341621..2cff8e1 100644 --- a/src/components/shared/SettingsView.tsx +++ b/src/components/shared/SettingsView.tsx @@ -1,8 +1,46 @@ import { useState } from "react"; import { useUserStore } from "../../stores/user"; import { useLightningStore } from "../../stores/lightning"; +import { useMuteStore } from "../../stores/mute"; import { isValidNwcUri } from "../../lib/lightning/nwc"; import { getNDK, getStoredRelayUrls, addRelay, removeRelay } from "../../lib/nostr"; +import { useProfile } from "../../hooks/useProfile"; + +function MutedRow({ pubkey, onUnmute }: { pubkey: string; onUnmute: () => void }) { + const profile = useProfile(pubkey); + const name = profile?.displayName || profile?.name || pubkey.slice(0, 12) + "…"; + return ( +
+ {profile?.picture && ( + + )} + {name} + +
+ ); +} + +function MuteSection() { + const { mutedPubkeys, unmute } = useMuteStore(); + if (mutedPubkeys.length === 0) return null; + return ( +
+

+ Muted accounts ({mutedPubkeys.length}) +

+
+ {mutedPubkeys.map((pk) => ( + unmute(pk)} /> + ))} +
+
+ ); +} function RelayRow({ url, onRemove }: { url: string; onRemove: () => void }) { const ndk = getNDK(); @@ -203,6 +241,7 @@ export function SettingsView() { +
); diff --git a/src/lib/nostr/client.ts b/src/lib/nostr/client.ts index f55a00d..b3900a1 100644 --- a/src/lib/nostr/client.ts +++ b/src/lib/nostr/client.ts @@ -274,6 +274,27 @@ export async function publishContactList(pubkeys: string[]): Promise { await event.publish(); } +export async function fetchMuteList(pubkey: string): Promise { + const instance = getNDK(); + const filter: NDKFilter = { kinds: [10000 as NDKKind], authors: [pubkey], limit: 1 }; + const events = await instance.fetchEvents(filter, { + cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, + }); + if (events.size === 0) return []; + const event = Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0]; + return event.tags.filter((t) => t[0] === "p" && t[1]).map((t) => t[1]); +} + +export async function publishMuteList(pubkeys: string[]): Promise { + const instance = getNDK(); + if (!instance.signer) return; + const event = new NDKEvent(instance); + event.kind = 10000 as NDKKind; + event.content = ""; + event.tags = pubkeys.map((pk) => ["p", pk]); + await event.publish(); +} + export async function fetchProfile(pubkey: string) { const instance = getNDK(); const user = instance.getUser({ pubkey }); diff --git a/src/lib/nostr/index.ts b/src/lib/nostr/index.ts index 06e18b5..88af812 100644 --- a/src/lib/nostr/index.ts +++ b/src/lib/nostr/index.ts @@ -1 +1 @@ -export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishReply, publishContactList, fetchReactionCount, fetchUserNotes, fetchProfile, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers } from "./client"; +export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishReply, publishContactList, fetchReactionCount, fetchUserNotes, fetchProfile, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers } from "./client"; diff --git a/src/stores/mute.ts b/src/stores/mute.ts new file mode 100644 index 0000000..c4beab4 --- /dev/null +++ b/src/stores/mute.ts @@ -0,0 +1,57 @@ +import { create } from "zustand"; +import { fetchMuteList, publishMuteList } from "../lib/nostr"; + +const STORAGE_KEY = "wrystr_mutes"; + +function loadLocal(): string[] { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); + } catch { + return []; + } +} + +function saveLocal(pubkeys: string[]) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(pubkeys)); +} + +interface MuteState { + mutedPubkeys: string[]; + fetchMuteList: (pubkey: string) => Promise; + mute: (pubkey: string) => Promise; + unmute: (pubkey: string) => Promise; +} + +export const useMuteStore = create((set, get) => ({ + mutedPubkeys: loadLocal(), + + 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 }); + saveLocal(merged); + } catch { + // Non-critical — local mutes still work + } + }, + + mute: async (pubkey: string) => { + const { mutedPubkeys } = get(); + if (mutedPubkeys.includes(pubkey)) return; + const updated = [...mutedPubkeys, pubkey]; + set({ mutedPubkeys: updated }); + saveLocal(updated); + publishMuteList(updated).catch(() => {}); // best-effort relay publish + }, + + unmute: async (pubkey: string) => { + const updated = get().mutedPubkeys.filter((p) => p !== pubkey); + set({ mutedPubkeys: updated }); + saveLocal(updated); + publishMuteList(updated).catch(() => {}); + }, +})); diff --git a/src/stores/user.ts b/src/stores/user.ts index 9c47ff3..0512c1e 100644 --- a/src/stores/user.ts +++ b/src/stores/user.ts @@ -3,6 +3,7 @@ import { NDKPrivateKeySigner } 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"; +import { useMuteStore } from "./mute"; export interface SavedAccount { pubkey: string; @@ -97,9 +98,10 @@ export const useUserStore = create((set, get) => ({ // Store nsec in OS keychain (best-effort — gracefully ignored if unavailable) invoke("store_nsec", { pubkey, nsec: nsecInput }).catch(() => {}); - // Fetch profile and follows + // Fetch profile, follows, and mute list get().fetchOwnProfile(); get().fetchFollows(); + useMuteStore.getState().fetchMuteList(pubkey); } catch (err) { set({ loginError: `Login failed: ${err}` }); } @@ -134,6 +136,7 @@ export const useUserStore = create((set, get) => ({ get().fetchOwnProfile(); get().fetchFollows(); + useMuteStore.getState().fetchMuteList(pubkey); } catch (err) { set({ loginError: `Login failed: ${err}` }); }