From cfbf24495598f48041811042b8d1afaf9c06a508 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Thu, 26 Jan 2023 12:34:18 +0100 Subject: [PATCH] feat: NIP-51 --- README.md | 3 +- src/Element/Invoice.tsx | 4 +-- src/Element/LogoutButton.tsx | 14 +++++++++ src/Element/MuteButton.tsx | 21 +++++++++++++ src/Element/MutedList.tsx | 39 +++++++++++++++++++++++ src/Element/Note.tsx | 5 ++- src/Element/NoteFooter.tsx | 8 ++++- src/Element/NoteReaction.tsx | 5 ++- src/Element/Timeline.tsx | 21 ++++++++----- src/Feed/EventPublisher.ts | 13 ++++++++ src/Feed/LoginFeed.ts | 29 +++++++++++++++--- src/Feed/MuteList.ts | 51 +++++++++++++++++++++++++++++++ src/Hooks/useModeration.tsx | 56 ++++++++++++++++++++++++++++++++++ src/Nostr/EventKind.ts | 3 +- src/Nostr/Subscriptions.ts | 13 ++++++-- src/Nostr/index.ts | 3 +- src/Pages/MessagesPage.tsx | 6 ++-- src/Pages/ProfilePage.tsx | 15 +++++++-- src/Pages/settings/Profile.tsx | 7 ++--- src/State/Login.ts | 25 +++++++++++++-- src/index.css | 8 ++--- 21 files changed, 314 insertions(+), 35 deletions(-) create mode 100644 src/Element/LogoutButton.tsx create mode 100644 src/Element/MuteButton.tsx create mode 100644 src/Element/MutedList.tsx create mode 100644 src/Feed/MuteList.ts create mode 100644 src/Hooks/useModeration.tsx diff --git a/README.md b/README.md index eb77f2b1..ffd7801c 100644 --- a/README.md +++ b/README.md @@ -25,4 +25,5 @@ Snort supports the following NIP's - [ ] NIP-28: Public Chat - [ ] NIP-36: Sensitive Content - [ ] NIP-40: Expiration Timestamp -- [ ] NIP-42: Authentication of clients to relays \ No newline at end of file +- [ ] NIP-42: Authentication of clients to relays +- [ ] NIP-51: Lists diff --git a/src/Element/Invoice.tsx b/src/Element/Invoice.tsx index ffeae1f4..80ad2cb5 100644 --- a/src/Element/Invoice.tsx +++ b/src/Element/Invoice.tsx @@ -79,9 +79,9 @@ export default function Invoice(props: InvoiceProps) { {info?.expired ?
Expired
: ( -
+
+ )} diff --git a/src/Element/LogoutButton.tsx b/src/Element/LogoutButton.tsx new file mode 100644 index 00000000..bb25af72 --- /dev/null +++ b/src/Element/LogoutButton.tsx @@ -0,0 +1,14 @@ +import { useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; + +import { logout } from "State/Login"; + +export default function LogoutButton(){ + const dispatch = useDispatch() + const navigate = useNavigate() + return ( + + ) +} diff --git a/src/Element/MuteButton.tsx b/src/Element/MuteButton.tsx new file mode 100644 index 00000000..8d344751 --- /dev/null +++ b/src/Element/MuteButton.tsx @@ -0,0 +1,21 @@ +import { HexKey } from "Nostr"; +import useModeration from "Hooks/useModeration"; + +interface MuteButtonProps { + pubkey: HexKey +} + +const MuteButton = ({ pubkey }: MuteButtonProps) => { + const { mute, unmute, isMuted } = useModeration() + return isMuted(pubkey) ? ( + + ) : ( + + ) +} + +export default MuteButton diff --git a/src/Element/MutedList.tsx b/src/Element/MutedList.tsx new file mode 100644 index 00000000..f4c2182c --- /dev/null +++ b/src/Element/MutedList.tsx @@ -0,0 +1,39 @@ +import { useMemo } from "react"; +import { useSelector } from "react-redux"; + +import { HexKey } from "Nostr"; import type { RootState } from "State/Store"; +import MuteButton from "Element/MuteButton"; +import ProfilePreview from "Element/ProfilePreview"; +import useMutedFeed, { getMuted } from "Feed/MuteList"; +import useModeration from "Hooks/useModeration"; + +export interface MutedListProps { + pubkey: HexKey +} + +export default function MutedList({ pubkey }: MutedListProps) { + const { publicKey } = useSelector((s: RootState) => s.login) + const { muted, isMuted, mute, unmute, muteAll } = useModeration(); + const feed = useMutedFeed(pubkey) + const pubkeys = useMemo(() => { + return publicKey === pubkey ? muted : getMuted(feed.store, pubkey); + }, [feed, pubkey]); + const hasAllMuted = pubkeys.every(isMuted) + + return ( +
+
+
{`${pubkeys?.length} muted`}
+ +
+ {pubkeys?.map(a => { + return } pubkey={a} options={{ about: false }} key={a} /> + })} +
+ ) +} diff --git a/src/Element/Note.tsx b/src/Element/Note.tsx index 68a54288..82a67fd1 100644 --- a/src/Element/Note.tsx +++ b/src/Element/Note.tsx @@ -12,6 +12,7 @@ import EventKind from "Nostr/EventKind"; import useProfile from "Feed/ProfileFeed"; import { TaggedRawEvent, u256 } from "Nostr"; import { useInView } from "react-intersection-observer"; +import useModeration from "Hooks/useModeration"; export interface NoteProps { data?: TaggedRawEvent, @@ -33,6 +34,8 @@ export default function Note(props: NoteProps) { const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]); const users = useProfile(pubKeys); const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]); + const { isMuted } = useModeration() + const muted = isMuted(ev.PubKey) const { ref, inView } = useInView({ triggerOnce: true }); const options = { @@ -150,7 +153,7 @@ export default function Note(props: NoteProps) { ) } - return ( + return muted ? null : (
{content()}
diff --git a/src/Element/NoteFooter.tsx b/src/Element/NoteFooter.tsx index a715e85b..72b73600 100644 --- a/src/Element/NoteFooter.tsx +++ b/src/Element/NoteFooter.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { useSelector } from "react-redux"; -import { faTrash, faRepeat, faShareNodes, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { faTrash, faRepeat, faShareNodes, faCopy, faCommentSlash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Menu, MenuItem } from '@szhsin/react-menu'; @@ -20,6 +20,7 @@ import { RootState } from "State/Store"; import { HexKey, TaggedRawEvent } from "Nostr"; import EventKind from "Nostr/EventKind"; import { UserPreferences } from "State/Login"; +import useModeration from "Hooks/useModeration"; export interface NoteFooterProps { related: TaggedRawEvent[], @@ -30,6 +31,7 @@ export default function NoteFooter(props: NoteFooterProps) { const { related, ev } = props; const login = useSelector(s => s.login.publicKey); + const { mute } = useModeration(); const prefs = useSelector(s => s.login.preferences); const author = useProfile(ev.RootPubKey)?.get(ev.RootPubKey); const publisher = useEventPublisher(); @@ -169,6 +171,10 @@ export default function NoteFooter(props: NoteFooterProps) { Copy ID + mute(ev.PubKey)}> + + Mute author + {prefs.showDebugMenus && ( copyEvent()}> diff --git a/src/Element/NoteReaction.tsx b/src/Element/NoteReaction.tsx index dc5bd62f..56de9de6 100644 --- a/src/Element/NoteReaction.tsx +++ b/src/Element/NoteReaction.tsx @@ -9,6 +9,7 @@ import { default as NEvent } from "Nostr/Event"; import { eventLink, hexToBech32 } from "Util"; import NoteTime from "Element/NoteTime"; import { RawEvent, TaggedRawEvent } from "Nostr"; +import useModeration from "Hooks/useModeration"; export interface NoteReactionProps { data?: TaggedRawEvent, @@ -18,6 +19,7 @@ export interface NoteReactionProps { export default function NoteReaction(props: NoteReactionProps) { const { ["data-ev"]: dataEv, data } = props; const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv]) + const { isMuted } = useModeration(); const refEvent = useMemo(() => { if (ev) { @@ -53,8 +55,9 @@ export default function NoteReaction(props: NoteReactionProps) { showHeader: ev?.Kind === EventKind.Repost, showFooter: false, }; + const isOpMuted = root && isMuted(root.pubkey) - return ( + return isOpMuted ? null : (
diff --git a/src/Element/Timeline.tsx b/src/Element/Timeline.tsx index 89e04cbf..447b06cb 100644 --- a/src/Element/Timeline.tsx +++ b/src/Element/Timeline.tsx @@ -1,13 +1,16 @@ import "./Timeline.css"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faForward } from "@fortawesome/free-solid-svg-icons"; import { useCallback, useMemo } from "react"; +import { useSelector } from "react-redux"; + import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed"; import { TaggedRawEvent } from "Nostr"; import EventKind from "Nostr/EventKind"; import LoadMore from "Element/LoadMore"; import Note from "Element/Note"; import NoteReaction from "Element/NoteReaction"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faForward } from "@fortawesome/free-solid-svg-icons"; +import type { RootState } from "State/Store"; export interface TimelineProps { postsOnly: boolean, @@ -19,21 +22,22 @@ export interface TimelineProps { * A list of notes by pubkeys */ export default function Timeline({ subject, postsOnly = false, method }: TimelineProps) { + const muted = useSelector((s: RootState) => s.login.muted) const { main, related, latest, parent, loadMore, showLatest } = useTimelineFeed(subject, { method }); const filterPosts = useCallback((nts: TaggedRawEvent[]) => { - return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true); - }, [postsOnly]); + return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true).filter(a => !muted.includes(a.pubkey)); + }, [postsOnly, muted]); const mainFeed = useMemo(() => { return filterPosts(main.notes); }, [main, filterPosts]); const latestFeed = useMemo(() => { - return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id)); - }, [latest, mainFeed, filterPosts]); + return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id)) + }, [latest, mainFeed, filterPosts, muted]); function eventElement(e: TaggedRawEvent) { switch (e.kind) { @@ -43,7 +47,10 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin case EventKind.Reaction: case EventKind.Repost: { let eRef = e.tags.find(a => a[0] === "e")?.at(1); - return a.id === eRef)}/> + let pRef = e.tags.find(a => a[0] === "p")?.at(1); + return !muted.includes(pRef || '') ? ( + a.id === eRef)}/> + ) : null } } } diff --git a/src/Feed/EventPublisher.ts b/src/Feed/EventPublisher.ts index 125cf17b..842aad45 100644 --- a/src/Feed/EventPublisher.ts +++ b/src/Feed/EventPublisher.ts @@ -95,6 +95,19 @@ export default function useEventPublisher() { } } }, + muted: async (keys: HexKey[]) => { + if (pubKey) { + let ev = NEvent.ForPubKey(pubKey); + ev.Kind = EventKind.Lists; + ev.Tags.push(new Tag(["d", "mute"], ev.Tags.length)) + keys.forEach(p => { + ev.Tags.push(new Tag(["p", p], ev.Tags.length)) + }) + // todo: public/private block + ev.Content = ""; + return await signEvent(ev); + } + }, metadata: async (obj: UserMetadata) => { if (pubKey) { let ev = NEvent.ForPubKey(pubKey); diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts index 7c4521f2..7a762451 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -5,10 +5,11 @@ import { useDispatch, useSelector } from "react-redux"; import { HexKey, TaggedRawEvent } from "Nostr"; import EventKind from "Nostr/EventKind"; import { Subscriptions } from "Nostr/Subscriptions"; -import { addDirectMessage, addNotifications, setFollows, setRelays } from "State/Login"; +import { addDirectMessage, addNotifications, setFollows, setRelays, setMuted } from "State/Login"; import { RootState } from "State/Store"; import { db } from "Db"; import useSubscription from "Feed/Subscription"; +import { MUTE_LIST_TAG, getMutedKeys } from "Feed/MuteList"; import { mapEventToProfile, MetadataCache } from "Db/User"; import { getDisplayName } from "Element/ProfileImage"; import { MentionRegex } from "Const"; @@ -18,7 +19,7 @@ import { MentionRegex } from "Const"; */ export default function useLoginFeed() { const dispatch = useDispatch(); - const [pubKey, readNotifications] = useSelector(s => [s.login.publicKey, s.login.readNotifications]); + const [pubKey, readNotifications, muted] = useSelector(s => [s.login.publicKey, s.login.readNotifications, s.login.muted]); const subMetadata = useMemo(() => { if (!pubKey) return null; @@ -42,6 +43,20 @@ export default function useLoginFeed() { return sub; }, [pubKey]); + const subMuted = useMemo(() => { + if (!pubKey) return null; + + let sub = new Subscriptions(); + sub.Id = "login:muted"; + sub.Kinds = new Set([EventKind.Lists]); + sub.Authors = new Set([pubKey]); + // TODO: not sure relay support this atm, don't seem to return results + // sub.DTags = new Set([MUTE_LIST_TAG]) + sub.Limit = 1; + + return sub; + }, [pubKey]); + const subDms = useMemo(() => { if (!pubKey) return null; @@ -61,6 +76,7 @@ export default function useLoginFeed() { const metadataFeed = useSubscription(subMetadata, { leaveOpen: true }); const notificationFeed = useSubscription(subNotification, { leaveOpen: true }); const dmsFeed = useSubscription(subDms, { leaveOpen: true }); + const mutedFeed = useSubscription(subMuted, { leaveOpen: true }); useEffect(() => { let contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList); @@ -96,7 +112,7 @@ export default function useLoginFeed() { }, [metadataFeed.store]); useEffect(() => { - let notifications = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote); + let notifications = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote && !muted.includes(a.pubkey)) if ("Notification" in window && Notification.permission === "granted") { for (let nx of notifications.filter(a => (a.created_at * 1000) > readNotifications)) { @@ -108,6 +124,11 @@ export default function useLoginFeed() { dispatch(addNotifications(notifications)); }, [notificationFeed.store]); + useEffect(() => { + const ps = getMutedKeys(mutedFeed.store.notes) + dispatch(setMuted(ps)) + }, [mutedFeed.store]) + useEffect(() => { let dms = dmsFeed.store.notes.filter(a => a.kind === EventKind.DirectMessage); dispatch(addDirectMessage(dms)); @@ -159,4 +180,4 @@ async function sendNotification(ev: TaggedRawEvent) { vibrate: [500] }); } -} \ No newline at end of file +} diff --git a/src/Feed/MuteList.ts b/src/Feed/MuteList.ts new file mode 100644 index 00000000..092d1233 --- /dev/null +++ b/src/Feed/MuteList.ts @@ -0,0 +1,51 @@ +import { useMemo } from "react"; +import { useSelector } from "react-redux"; + +import { HexKey, TaggedRawEvent } from "Nostr"; +import EventKind from "Nostr/EventKind"; +import { Subscriptions } from "Nostr/Subscriptions"; +import type { RootState } from "State/Store"; +import useSubscription, { NoteStore } from "Feed/Subscription"; + +export const MUTE_LIST_TAG = "mute" + +export default function useMutedFeed(pubkey: HexKey) { + const loginPubkey = useSelector((s: RootState) => s.login.publicKey) + const sub = useMemo(() => { + if (pubkey === loginPubkey) return null + + let sub = new Subscriptions(); + sub.Id = `muted:${pubkey}`; + sub.Kinds = new Set([EventKind.Lists]); + sub.Authors = new Set([pubkey]); + // TODO: not sure relay support this atm, don't seem to return results + //sub.DTags = new Set([MUTE_LIST_TAG]) + sub.Limit = 1; + + return sub; + }, [pubkey]); + + return useSubscription(sub); +} + +export function getMutedKeys(rawNotes: TaggedRawEvent[]): { at: number, keys: HexKey[] } { + const notes = [...rawNotes] + notes.sort((a, b) => a.created_at - b.created_at) + const newest = notes && notes[0] + if (newest) { + const { tags } = newest + const mutedIndex = tags.findIndex(t => t[0] === "d" && t[1] === MUTE_LIST_TAG) + if (mutedIndex !== -1) { + return { + at: newest.created_at, + keys: tags.slice(mutedIndex).filter(t => t[0] === "p").map(t => t[1]) + } + } + } + return { at: 0, keys: [] } +} + +export function getMuted(feed: NoteStore, pubkey: HexKey): HexKey[] { + let lists = feed?.notes.filter(a => a.kind === EventKind.Lists && a.pubkey === pubkey); + return getMutedKeys(lists).keys; +} diff --git a/src/Hooks/useModeration.tsx b/src/Hooks/useModeration.tsx new file mode 100644 index 00000000..b8aebb12 --- /dev/null +++ b/src/Hooks/useModeration.tsx @@ -0,0 +1,56 @@ +import { useSelector, useDispatch } from "react-redux"; + +import type { RootState } from "State/Store"; +import { HexKey } from "Nostr"; +import useEventPublisher from "Feed/EventPublisher"; +import { setMuted } from "State/Login"; + + +export default function useModeration() { + const dispatch = useDispatch() + const { muted } = useSelector((s: RootState) => s.login) + const publisher = useEventPublisher() + + async function setMutedList(ids: HexKey[]) { + try { + const ev = await publisher.muted(ids) + console.debug(ev); + publisher.broadcast(ev) + } catch (error) { + console.debug("Couldn't change mute list") + } + } + + function isMuted(id: HexKey) { + return muted.includes(id) + } + + function unmute(id: HexKey) { + const newMuted = muted.filter(p => p !== id) + dispatch(setMuted({ + at: new Date().getTime(), + keys: newMuted + })) + setMutedList(newMuted) + } + + function mute(id: HexKey) { + const newMuted = muted.concat([id]) + setMutedList(newMuted) + dispatch(setMuted({ + at: new Date().getTime(), + keys: newMuted + })) + } + + function muteAll(ids: HexKey[]) { + const newMuted = Array.from(new Set(muted.concat(ids))) + setMutedList(newMuted) + dispatch(setMuted({ + at: new Date().getTime(), + keys: newMuted + })) + } + + return { muted, mute, muteAll, unmute, isMuted } +} diff --git a/src/Nostr/EventKind.ts b/src/Nostr/EventKind.ts index d12012b9..b8a0de04 100644 --- a/src/Nostr/EventKind.ts +++ b/src/Nostr/EventKind.ts @@ -7,7 +7,8 @@ const enum EventKind { DirectMessage = 4, // NIP-04 Deletion = 5, // NIP-09 Repost = 6, // NIP-18 - Reaction = 7 // NIP-25 + Reaction = 7, // NIP-25 + Lists = 30000, // NIP-51 }; export default EventKind; \ No newline at end of file diff --git a/src/Nostr/Subscriptions.ts b/src/Nostr/Subscriptions.ts index b9af235a..47854f8b 100644 --- a/src/Nostr/Subscriptions.ts +++ b/src/Nostr/Subscriptions.ts @@ -42,6 +42,11 @@ export class Subscriptions { */ HashTags?: Set; + /** + * A list of "d" tags to search + */ + DTags?: Set; + /** * a timestamp, events must be newer than this to pass */ @@ -89,6 +94,7 @@ export class Subscriptions { this.Kinds = sub?.kinds ? new Set(sub.kinds) : undefined; this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : undefined; this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : undefined; + this.DTags = sub?.["#d"] ? new Set(sub["#d"]) : undefined; this.Since = sub?.since ?? undefined; this.Until = sub?.until ?? undefined; this.Limit = sub?.limit ?? undefined; @@ -130,9 +136,12 @@ export class Subscriptions { if (this.PTags) { ret["#p"] = Array.from(this.PTags); } - if(this.HashTags) { + if (this.HashTags) { ret["#t"] = Array.from(this.HashTags); } + if (this.DTags) { + ret["#d"] = Array.from(this.DTags); + } if (this.Since !== null) { ret.since = this.Since; } @@ -144,4 +153,4 @@ export class Subscriptions { } return ret; } -} \ No newline at end of file +} diff --git a/src/Nostr/index.ts b/src/Nostr/index.ts index cac6946e..3b016337 100644 --- a/src/Nostr/index.ts +++ b/src/Nostr/index.ts @@ -35,6 +35,7 @@ export type RawReqFilter = { "#e"?: u256[], "#p"?: u256[], "#t"?: string[], + "#d"?: string[], since?: number, until?: number, limit?: number @@ -53,4 +54,4 @@ export type UserMetadata = { nip05?: string, lud06?: string, lud16?: string -} \ No newline at end of file +} diff --git a/src/Pages/MessagesPage.tsx b/src/Pages/MessagesPage.tsx index f0936fb4..9895e067 100644 --- a/src/Pages/MessagesPage.tsx +++ b/src/Pages/MessagesPage.tsx @@ -8,6 +8,7 @@ import { hexToBech32 } from "../Util"; import { incDmInteraction } from "State/Login"; import { RootState } from "State/Store"; import NoteToSelf from "Element/NoteToSelf"; +import useModeration from "Hooks/useModeration"; type DmChat = { pubkey: HexKey, @@ -20,10 +21,11 @@ export default function MessagesPage() { const myPubKey = useSelector(s => s.login.publicKey); const dms = useSelector(s => s.login.dms); const dmInteraction = useSelector(s => s.login.dmInteraction); + const { muted, isMuted } = useModeration(); const chats = useMemo(() => { - return extractChats(dms, myPubKey!); - }, [dms, myPubKey, dmInteraction]); + return extractChats(dms.filter(a => !isMuted(a.pubkey)), myPubKey!); + }, [dms, myPubKey, dmInteraction, muted]); function noteToSelf(chat: DmChat) { return ( diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index 8a04440f..728d2773 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -10,6 +10,7 @@ import useProfile from "Feed/ProfileFeed"; import FollowButton from "Element/FollowButton"; import { extractLnAddress, parseId, hexToBech32 } from "Util"; import Avatar from "Element/Avatar"; +import LogoutButton from "Element/LogoutButton"; import Timeline from "Element/Timeline"; import Text from 'Element/Text' import LNURLTip from "Element/LNURLTip"; @@ -17,6 +18,7 @@ import Nip05 from "Element/Nip05"; import Copy from "Element/Copy"; import ProfilePreview from "Element/ProfilePreview"; import FollowersList from "Element/FollowersList"; +import MutedList from "Element/MutedList"; import FollowsList from "Element/FollowsList"; import { RootState } from "State/Store"; import { HexKey } from "Nostr"; @@ -26,7 +28,8 @@ enum ProfileTab { Notes = "Notes", Reactions = "Reactions", Followers = "Followers", - Follows = "Follows" + Follows = "Follows", + Muted = "Muted" }; export default function ProfilePage() { @@ -35,6 +38,8 @@ export default function ProfilePage() { const id = useMemo(() => parseId(params.id!), [params]); const user = useProfile(id)?.get(id); const loggedOut = useSelector(s => s.login.loggedOut); + const muted = useSelector(s => s.login.muted); + const isMuted = useMemo(() => muted.includes(id), [muted, id]) const loginPubKey = useSelector(s => s.login.publicKey); const follows = useSelector(s => s.login.follows); const isMe = loginPubKey === id; @@ -119,6 +124,9 @@ export default function ProfilePage() { case ProfileTab.Followers: { return } + case ProfileTab.Muted: { + return + } } } @@ -136,9 +144,12 @@ export default function ProfilePage() { {username()}
{isMe ? ( + <> + + ) : ( !loggedOut && ( <> @@ -165,7 +176,7 @@ export default function ProfilePage() {
- {[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(v => { + {[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Muted].map(v => { return
setTab(v)}>{v}
})}
diff --git a/src/Pages/settings/Profile.tsx b/src/Pages/settings/Profile.tsx index bd96889f..5e95d27e 100644 --- a/src/Pages/settings/Profile.tsx +++ b/src/Pages/settings/Profile.tsx @@ -2,7 +2,7 @@ import "./Profile.css"; import Nostrich from "nostrich.jpg"; import { useEffect, useState } from "react"; -import { useDispatch, useSelector } from "react-redux"; +import { useSelector } from "react-redux"; import { useNavigate } from "react-router-dom"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faShop } from "@fortawesome/free-solid-svg-icons"; @@ -10,7 +10,7 @@ import { faShop } from "@fortawesome/free-solid-svg-icons"; import useEventPublisher from "Feed/EventPublisher"; import useProfile from "Feed/ProfileFeed"; import VoidUpload from "Feed/VoidUpload"; -import { logout } from "State/Login"; +import LogoutButton from "Element/LogoutButton"; import { hexToBech32, openFile } from "Util"; import Copy from "Element/Copy"; import { RootState } from "State/Store"; @@ -20,7 +20,6 @@ export default function ProfileSettings() { const navigate = useNavigate(); const id = useSelector(s => s.login.publicKey); const privKey = useSelector(s => s.login.privateKey); - const dispatch = useDispatch(); const user = useProfile(id)?.get(id || ""); const publisher = useEventPublisher(); @@ -143,7 +142,7 @@ export default function ProfileSettings() {
- +
diff --git a/src/State/Login.ts b/src/State/Login.ts index ca009db4..e3fdb8f7 100644 --- a/src/State/Login.ts +++ b/src/State/Login.ts @@ -72,6 +72,16 @@ export interface LoginStore { */ follows: HexKey[], + /** + * A list of pubkeys this user has muted + */ + muted: HexKey[], + + /** + * Last seen mute list event timestamp + */ + lastMutedSeenAt: number, + /** * Notifications for this login session */ @@ -105,6 +115,8 @@ const InitState = { relays: {}, latestRelays: 0, follows: [], + lastMutedSeenAt: 0, + muted: [], notifications: [], readNotifications: new Date().getTime(), dms: [], @@ -207,6 +219,14 @@ const LoginSlice = createSlice({ state.follows = Array.from(existing); } }, + setMuted(state, action: PayloadAction<{at: number, keys: HexKey[]}>) { + const { at, keys } = action.payload + if (at > state.lastMutedSeenAt) { + const muted = new Set([...keys]) + state.muted = Array.from(muted) + state.lastMutedSeenAt = at + } + }, addNotifications: (state, action: PayloadAction) => { let n = action.payload; if (!Array.isArray(n)) { @@ -273,10 +293,11 @@ export const { removeRelay, setFollows, addNotifications, + setMuted, addDirectMessage, incDmInteraction, logout, markNotificationsRead, - setPreferences + setPreferences, } = LoginSlice.actions; -export const reducer = LoginSlice.reducer; \ No newline at end of file +export const reducer = LoginSlice.reducer; diff --git a/src/index.css b/src/index.css index 3b953bcb..81aefb4f 100644 --- a/src/index.css +++ b/src/index.css @@ -124,10 +124,6 @@ button:disabled { cursor: not-allowed; color: var(--gray); } -.light button:disabled { - color: var(--font-color); -} - button:hover { background-color: var(--font-color); @@ -487,3 +483,7 @@ body.scroll-lock { .bold { font-weight: 700; } + +.blurred { + filter: blur(5px); +}