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