From cfbf24495598f48041811042b8d1afaf9c06a508 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Thu, 26 Jan 2023 12:34:18 +0100 Subject: [PATCH 01/13] 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); +} From a500c040f9d7597a17814147bf07a0506e72d803 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 27 Jan 2023 08:16:06 +0100 Subject: [PATCH 02/13] remove unused code --- src/Pages/ProfilePage.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index 728d2773..3f80d216 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -38,8 +38,6 @@ 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; From ba2fde425faf072a1f3f46d99cae1759e3adab3f Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 27 Jan 2023 08:21:48 +0100 Subject: [PATCH 03/13] refactor: clean up --- src/Element/Timeline.tsx | 7 ++++--- src/Feed/LoginFeed.ts | 6 ++++-- src/index.css | 4 ---- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/Element/Timeline.tsx b/src/Element/Timeline.tsx index 447b06cb..2e95feb9 100644 --- a/src/Element/Timeline.tsx +++ b/src/Element/Timeline.tsx @@ -11,6 +11,7 @@ import LoadMore from "Element/LoadMore"; import Note from "Element/Note"; import NoteReaction from "Element/NoteReaction"; import type { RootState } from "State/Store"; +import useModeration from "Hooks/useModeration"; export interface TimelineProps { postsOnly: boolean, @@ -22,13 +23,13 @@ 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 { muted, isMuted } = useModeration(); 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).filter(a => !muted.includes(a.pubkey)); + return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true).filter(a => !isMuted(a.pubkey)); }, [postsOnly, muted]); const mainFeed = useMemo(() => { @@ -37,7 +38,7 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin const latestFeed = useMemo(() => { return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id)) - }, [latest, mainFeed, filterPosts, muted]); + }, [latest, mainFeed, filterPosts]); function eventElement(e: TaggedRawEvent) { switch (e.kind) { diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts index 7a762451..9ca50ffc 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -12,6 +12,7 @@ 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 useModeration from "Hooks/useModeration"; import { MentionRegex } from "Const"; /** @@ -19,7 +20,8 @@ import { MentionRegex } from "Const"; */ export default function useLoginFeed() { const dispatch = useDispatch(); - const [pubKey, readNotifications, muted] = useSelector(s => [s.login.publicKey, s.login.readNotifications, s.login.muted]); + const { isMuted } = useModeration(); + const [pubKey, readNotifications] = useSelector(s => [s.login.publicKey, s.login.readNotifications]); const subMetadata = useMemo(() => { if (!pubKey) return null; @@ -112,7 +114,7 @@ export default function useLoginFeed() { }, [metadataFeed.store]); useEffect(() => { - let notifications = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote && !muted.includes(a.pubkey)) + let notifications = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote && !isMuted(a.pubkey)) if ("Notification" in window && Notification.permission === "granted") { for (let nx of notifications.filter(a => (a.created_at * 1000) > readNotifications)) { diff --git a/src/index.css b/src/index.css index 81aefb4f..f6b76757 100644 --- a/src/index.css +++ b/src/index.css @@ -483,7 +483,3 @@ body.scroll-lock { .bold { font-weight: 700; } - -.blurred { - filter: blur(5px); -} From 613665c18709025e5413da70cc030f44bcf3af56 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 27 Jan 2023 11:47:05 +0100 Subject: [PATCH 04/13] fix: correctly filter with tag --- src/Feed/EventPublisher.ts | 4 +++- src/Feed/LoginFeed.ts | 3 +-- src/Feed/MuteList.ts | 3 +-- src/Nostr/Tag.ts | 8 ++++++++ src/index.css | 4 ++++ 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/Feed/EventPublisher.ts b/src/Feed/EventPublisher.ts index 842aad45..f621cf7c 100644 --- a/src/Feed/EventPublisher.ts +++ b/src/Feed/EventPublisher.ts @@ -1,10 +1,12 @@ import { useSelector } from "react-redux"; + import { System } from "Nostr/System"; import { default as NEvent } from "Nostr/Event"; import EventKind from "Nostr/EventKind"; import Tag from "Nostr/Tag"; import { RootState } from "State/Store"; import { HexKey, RawEvent, u256, UserMetadata } from "Nostr"; +import { MUTE_LIST_TAG } from "Feed/MuteList"; import { bech32ToHex } from "Util" import { DefaultRelays, HashtagRegex } from "Const"; @@ -99,7 +101,7 @@ export default function useEventPublisher() { if (pubKey) { let ev = NEvent.ForPubKey(pubKey); ev.Kind = EventKind.Lists; - ev.Tags.push(new Tag(["d", "mute"], ev.Tags.length)) + ev.Tags.push(new Tag(["d", MUTE_LIST_TAG], ev.Tags.length)) keys.forEach(p => { ev.Tags.push(new Tag(["p", p], ev.Tags.length)) }) diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts index 9ca50ffc..71ff6c2b 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -52,8 +52,7 @@ export default function useLoginFeed() { 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.DTags = new Set([MUTE_LIST_TAG]) sub.Limit = 1; return sub; diff --git a/src/Feed/MuteList.ts b/src/Feed/MuteList.ts index 092d1233..4b71f5f2 100644 --- a/src/Feed/MuteList.ts +++ b/src/Feed/MuteList.ts @@ -18,8 +18,7 @@ export default function useMutedFeed(pubkey: HexKey) { 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.DTags = new Set([MUTE_LIST_TAG]) sub.Limit = 1; return sub; diff --git a/src/Nostr/Tag.ts b/src/Nostr/Tag.ts index e10af861..6ee98eec 100644 --- a/src/Nostr/Tag.ts +++ b/src/Nostr/Tag.ts @@ -8,6 +8,7 @@ export default class Tag { Relay?: string; Marker?: string; Hashtag?: string; + DTag?: string; Index: number; Invalid: boolean; @@ -36,6 +37,10 @@ export default class Tag { } break; } + case "d": { + this.DTag = tag[1]; + break; + } case "t": { this.Hashtag = tag[1]; break; @@ -61,6 +66,9 @@ export default class Tag { case "t": { return ["t", this.Hashtag!]; } + case "d": { + return ["t", this.DTag!]; + } default: { return this.Original; } diff --git a/src/index.css b/src/index.css index f6b76757..74bed443 100644 --- a/src/index.css +++ b/src/index.css @@ -124,6 +124,10 @@ button:disabled { cursor: not-allowed; color: var(--gray); } +.light button:disabled { + color: var(--font-secondary-color); + border-color: var(--font-secondary-color); +} button:hover { background-color: var(--font-color); From 5817c36af12539d336909f68f0ee2c6acd9ffad1 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 27 Jan 2023 14:46:32 +0100 Subject: [PATCH 05/13] fix color --- src/index.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/index.css b/src/index.css index 74bed443..7968fb22 100644 --- a/src/index.css +++ b/src/index.css @@ -124,6 +124,11 @@ button:disabled { cursor: not-allowed; color: var(--gray); } + +.light button.transparent { + color: var(--font-color); +} + .light button:disabled { color: var(--font-secondary-color); border-color: var(--font-secondary-color); From c183633efc434ec988ae40a06ce8748bfe537731 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 27 Jan 2023 14:46:59 +0100 Subject: [PATCH 06/13] fix: use standard mute list tag --- src/Feed/MuteList.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Feed/MuteList.ts b/src/Feed/MuteList.ts index 4b71f5f2..40134593 100644 --- a/src/Feed/MuteList.ts +++ b/src/Feed/MuteList.ts @@ -7,7 +7,7 @@ import { Subscriptions } from "Nostr/Subscriptions"; import type { RootState } from "State/Store"; import useSubscription, { NoteStore } from "Feed/Subscription"; -export const MUTE_LIST_TAG = "mute" +export const MUTE_LIST_TAG = "p:mute" export default function useMutedFeed(pubkey: HexKey) { const loginPubkey = useSelector((s: RootState) => s.login.publicKey) From 592a8b04c0bde1f62d5150ec7582fe865911c02b Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 27 Jan 2023 18:35:55 +0100 Subject: [PATCH 07/13] fix: dont take into account old contact lists --- src/Feed/EventPublisher.ts | 2 +- src/Feed/LoginFeed.ts | 3 ++- src/Feed/MuteList.ts | 6 +++--- src/Hooks/useModeration.tsx | 6 +++--- src/State/Login.ts | 33 +++++++++++++++++++++++++-------- 5 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/Feed/EventPublisher.ts b/src/Feed/EventPublisher.ts index f621cf7c..5d2a4f66 100644 --- a/src/Feed/EventPublisher.ts +++ b/src/Feed/EventPublisher.ts @@ -304,4 +304,4 @@ const barierNip07 = async (then: () => Promise) => { } finally { isNip07Busy = false; } -}; \ No newline at end of file +}; diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts index 71ff6c2b..c5b99963 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -30,6 +30,7 @@ export default function useLoginFeed() { sub.Id = `login:meta`; sub.Authors = new Set([pubKey]); sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata]); + sub.Limit = 2 return sub; }, [pubKey]); @@ -92,7 +93,7 @@ export default function useLoginFeed() { dispatch(setRelays({ relays, createdAt: cl.created_at })); } let pTags = cl.tags.filter(a => a[0] === "p").map(a => a[1]); - dispatch(setFollows(pTags)); + dispatch(setFollows({ keys: pTags, createdAt: cl.created_at })); } (async () => { diff --git a/src/Feed/MuteList.ts b/src/Feed/MuteList.ts index 40134593..f5fd2d7c 100644 --- a/src/Feed/MuteList.ts +++ b/src/Feed/MuteList.ts @@ -27,7 +27,7 @@ export default function useMutedFeed(pubkey: HexKey) { return useSubscription(sub); } -export function getMutedKeys(rawNotes: TaggedRawEvent[]): { at: number, keys: HexKey[] } { +export function getMutedKeys(rawNotes: TaggedRawEvent[]): { createdAt: number, keys: HexKey[] } { const notes = [...rawNotes] notes.sort((a, b) => a.created_at - b.created_at) const newest = notes && notes[0] @@ -36,12 +36,12 @@ export function getMutedKeys(rawNotes: TaggedRawEvent[]): { at: number, keys: He const mutedIndex = tags.findIndex(t => t[0] === "d" && t[1] === MUTE_LIST_TAG) if (mutedIndex !== -1) { return { - at: newest.created_at, + createdAt: newest.created_at, keys: tags.slice(mutedIndex).filter(t => t[0] === "p").map(t => t[1]) } } } - return { at: 0, keys: [] } + return { createdAt: 0, keys: [] } } export function getMuted(feed: NoteStore, pubkey: HexKey): HexKey[] { diff --git a/src/Hooks/useModeration.tsx b/src/Hooks/useModeration.tsx index b8aebb12..ab529491 100644 --- a/src/Hooks/useModeration.tsx +++ b/src/Hooks/useModeration.tsx @@ -28,7 +28,7 @@ export default function useModeration() { function unmute(id: HexKey) { const newMuted = muted.filter(p => p !== id) dispatch(setMuted({ - at: new Date().getTime(), + createdAt: new Date().getTime(), keys: newMuted })) setMutedList(newMuted) @@ -38,7 +38,7 @@ export default function useModeration() { const newMuted = muted.concat([id]) setMutedList(newMuted) dispatch(setMuted({ - at: new Date().getTime(), + createdAt: new Date().getTime(), keys: newMuted })) } @@ -47,7 +47,7 @@ export default function useModeration() { const newMuted = Array.from(new Set(muted.concat(ids))) setMutedList(newMuted) dispatch(setMuted({ - at: new Date().getTime(), + createdAt: new Date().getTime(), keys: newMuted })) } diff --git a/src/State/Login.ts b/src/State/Login.ts index e3fdb8f7..cc6dec7f 100644 --- a/src/State/Login.ts +++ b/src/State/Login.ts @@ -72,6 +72,11 @@ export interface LoginStore { */ follows: HexKey[], + /** + * Newest relay list timestamp + */ + latestFollows: number, + /** * A list of pubkeys this user has muted */ @@ -80,7 +85,7 @@ export interface LoginStore { /** * Last seen mute list event timestamp */ - lastMutedSeenAt: number, + latestMuted: number, /** * Notifications for this login session @@ -115,8 +120,9 @@ const InitState = { relays: {}, latestRelays: 0, follows: [], - lastMutedSeenAt: 0, + latestFollows: 0, muted: [], + latestMuted: 0, notifications: [], readNotifications: new Date().getTime(), dms: [], @@ -136,6 +142,11 @@ export interface SetRelaysPayload { createdAt: number }; +export interface SetFollowsPayload { + keys: HexKey[] + createdAt: number +}; + const LoginSlice = createSlice({ name: "Login", initialState: InitState, @@ -204,9 +215,14 @@ const LoginSlice = createSlice({ delete state.relays[action.payload]; state.relays = { ...state.relays }; }, - setFollows: (state, action: PayloadAction) => { + setFollows: (state, action: PayloadAction) => { + const { keys, createdAt } = action.payload + if (state.latestFollows > createdAt) { + return; + } + let existing = new Set(state.follows); - let update = Array.isArray(action.payload) ? action.payload : [action.payload]; + let update = Array.isArray(keys) ? keys : [keys]; let changes = false; for (let pk of update) { @@ -217,14 +233,15 @@ const LoginSlice = createSlice({ } if (changes) { state.follows = Array.from(existing); + state.latestFollows = createdAt; } }, - setMuted(state, action: PayloadAction<{at: number, keys: HexKey[]}>) { - const { at, keys } = action.payload - if (at > state.lastMutedSeenAt) { + setMuted(state, action: PayloadAction<{createdAt: number, keys: HexKey[]}>) { + const { createdAt, keys } = action.payload + if (createdAt > state.latestMuted) { const muted = new Set([...keys]) state.muted = Array.from(muted) - state.lastMutedSeenAt = at + state.latestMuted = createdAt } }, addNotifications: (state, action: PayloadAction) => { From 456aa5fb797e40056583d34ce2c32422cfd19b94 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 27 Jan 2023 22:10:14 +0100 Subject: [PATCH 08/13] address review comments and add private blocking --- src/Element/BlockButton.tsx | 21 +++++++ src/Element/BlockList.tsx | 27 ++++++++ src/Element/MutedList.tsx | 3 +- src/Element/Note.tsx | 31 +++++++-- src/Element/NoteFooter.tsx | 10 ++- src/Element/NoteReaction.tsx | 4 +- src/Element/Text.css | 7 +++ src/Element/Timeline.tsx | 7 +-- src/Feed/EventPublisher.ts | 22 ++++--- src/Feed/LoginFeed.ts | 119 ++++++++++++++--------------------- src/Feed/MuteList.ts | 36 +++++------ src/Hooks/useModeration.tsx | 54 +++++++++++----- src/Nostr/Event.ts | 32 +++++++--- src/Nostr/Subscriptions.ts | 10 +-- src/Nostr/Tag.ts | 4 +- src/Nostr/index.ts | 9 ++- src/Notifications.ts | 43 +++++++++++++ src/Pages/Login.tsx | 8 +-- src/Pages/MessagesPage.tsx | 8 +-- src/Pages/ProfilePage.tsx | 3 +- src/Pages/settings/Index.tsx | 22 ++++++- src/State/Login.ts | 81 ++++++++++++++---------- src/index.css | 42 ++++++------- 23 files changed, 385 insertions(+), 218 deletions(-) create mode 100644 src/Element/BlockButton.tsx create mode 100644 src/Element/BlockList.tsx create mode 100644 src/Notifications.ts diff --git a/src/Element/BlockButton.tsx b/src/Element/BlockButton.tsx new file mode 100644 index 00000000..f2cd5426 --- /dev/null +++ b/src/Element/BlockButton.tsx @@ -0,0 +1,21 @@ +import { HexKey } from "Nostr"; +import useModeration from "Hooks/useModeration"; + +interface BlockButtonProps { + pubkey: HexKey +} + +const BlockButton = ({ pubkey }: BlockButtonProps) => { + const { block, unblock, isBlocked } = useModeration() + return isBlocked(pubkey) ? ( + + ) : ( + + ) +} + +export default BlockButton diff --git a/src/Element/BlockList.tsx b/src/Element/BlockList.tsx new file mode 100644 index 00000000..7125f0f5 --- /dev/null +++ b/src/Element/BlockList.tsx @@ -0,0 +1,27 @@ +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 BlockButton from "Element/BlockButton"; +import ProfilePreview from "Element/ProfilePreview"; +import useMutedFeed, { getMuted } from "Feed/MuteList"; +import useModeration from "Hooks/useModeration"; + +export default function BlockList() { + const { publicKey } = useSelector((s: RootState) => s.login) + const { blocked, muted } = useModeration(); + + return ( +
+

Muted ({muted.length})

+ {muted.map(a => { + return } pubkey={a} options={{ about: false }} key={a} /> + })} +

Blocked ({blocked.length})

+ {blocked.map(a => { + return } pubkey={a} options={{ about: false }} key={a} /> + })} +
+ ) +} diff --git a/src/Element/MutedList.tsx b/src/Element/MutedList.tsx index f4c2182c..23657ab6 100644 --- a/src/Element/MutedList.tsx +++ b/src/Element/MutedList.tsx @@ -12,11 +12,10 @@ export interface MutedListProps { } 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); + return getMuted(feed.store, pubkey); }, [feed, pubkey]); const hasAllMuted = pubkeys.every(isMuted) diff --git a/src/Element/Note.tsx b/src/Element/Note.tsx index 82a67fd1..2094424c 100644 --- a/src/Element/Note.tsx +++ b/src/Element/Note.tsx @@ -1,5 +1,5 @@ import "./Note.css"; -import { useCallback, useMemo, ReactNode } from "react"; +import { useCallback, useMemo, useState, ReactNode } from "react"; import { useNavigate, Link } from "react-router-dom"; import { default as NEvent } from "Nostr/Event"; @@ -27,6 +27,23 @@ export interface NoteProps { ["data-ev"]?: NEvent } +const HiddenNote = ({ children }: any) => { + const [show, setShow] = useState(false) + return show ? children : ( +
+
+

+ This note was hidden because of your moderation settings +

+ +
+
+ ) +} + + export default function Note(props: NoteProps) { const navigate = useNavigate(); const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent } = props @@ -35,7 +52,7 @@ export default function Note(props: NoteProps) { const users = useProfile(pubKeys); const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]); const { isMuted } = useModeration() - const muted = isMuted(ev.PubKey) + const isOpMuted = isMuted(ev.PubKey) const { ref, inView } = useInView({ triggerOnce: true }); const options = { @@ -153,9 +170,11 @@ export default function Note(props: NoteProps) { ) } - return muted ? null : ( -
- {content()} -
+ const note = ( +
+ {content()} +
) + + return isOpMuted ? {note} : note } diff --git a/src/Element/NoteFooter.tsx b/src/Element/NoteFooter.tsx index 72b73600..42db172e 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, faCommentSlash } from "@fortawesome/free-solid-svg-icons"; +import { faTrash, faRepeat, faShareNodes, faCopy, faCommentSlash, faBan } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Menu, MenuItem } from '@szhsin/react-menu'; @@ -31,7 +31,7 @@ export default function NoteFooter(props: NoteFooterProps) { const { related, ev } = props; const login = useSelector(s => s.login.publicKey); - const { mute } = useModeration(); + const { mute, block } = useModeration(); const prefs = useSelector(s => s.login.preferences); const author = useProfile(ev.RootPubKey)?.get(ev.RootPubKey); const publisher = useEventPublisher(); @@ -173,7 +173,11 @@ export default function NoteFooter(props: NoteFooterProps) { mute(ev.PubKey)}> - Mute author + Mute + + block(ev.PubKey)}> + + Block {prefs.showDebugMenus && ( copyEvent()}> diff --git a/src/Element/NoteReaction.tsx b/src/Element/NoteReaction.tsx index 56de9de6..771b72a8 100644 --- a/src/Element/NoteReaction.tsx +++ b/src/Element/NoteReaction.tsx @@ -20,6 +20,8 @@ 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 pRef = data?.tags.find((a: any) => a[0] === "p")?.at(1); + const isRefMuted = pRef && isMuted(pRef) const refEvent = useMemo(() => { if (ev) { @@ -57,7 +59,7 @@ export default function NoteReaction(props: NoteReactionProps) { }; const isOpMuted = root && isMuted(root.pubkey) - return isOpMuted ? null : ( + return isOpMuted || isRefMuted ? null : (
diff --git a/src/Element/Text.css b/src/Element/Text.css index 0b13975f..8903a429 100644 --- a/src/Element/Text.css +++ b/src/Element/Text.css @@ -69,3 +69,10 @@ width: -webkit-fill-available; aspect-ratio: 16 / 9; } + +.text blockquote { + margin: 0; + color: var(--font-secondary-color); + border-left: 2px solid var(--font-secondary-color); + padding-left: 12px; +} diff --git a/src/Element/Timeline.tsx b/src/Element/Timeline.tsx index 2e95feb9..abf5ba56 100644 --- a/src/Element/Timeline.tsx +++ b/src/Element/Timeline.tsx @@ -2,7 +2,6 @@ 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"; @@ -10,7 +9,6 @@ import EventKind from "Nostr/EventKind"; import LoadMore from "Element/LoadMore"; import Note from "Element/Note"; import NoteReaction from "Element/NoteReaction"; -import type { RootState } from "State/Store"; import useModeration from "Hooks/useModeration"; export interface TimelineProps { @@ -48,10 +46,7 @@ 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); - let pRef = e.tags.find(a => a[0] === "p")?.at(1); - return !muted.includes(pRef || '') ? ( - a.id === eRef)}/> - ) : null + return a.id === eRef)}/> } } } diff --git a/src/Feed/EventPublisher.ts b/src/Feed/EventPublisher.ts index 5d2a4f66..c4d44a36 100644 --- a/src/Feed/EventPublisher.ts +++ b/src/Feed/EventPublisher.ts @@ -5,8 +5,7 @@ import { default as NEvent } from "Nostr/Event"; import EventKind from "Nostr/EventKind"; import Tag from "Nostr/Tag"; import { RootState } from "State/Store"; -import { HexKey, RawEvent, u256, UserMetadata } from "Nostr"; -import { MUTE_LIST_TAG } from "Feed/MuteList"; +import { HexKey, RawEvent, u256, UserMetadata, Lists } from "Nostr"; import { bech32ToHex } from "Util" import { DefaultRelays, HashtagRegex } from "Const"; @@ -97,16 +96,25 @@ export default function useEventPublisher() { } } }, - muted: async (keys: HexKey[]) => { + muted: async (keys: HexKey[], priv: HexKey[]) => { if (pubKey) { let ev = NEvent.ForPubKey(pubKey); ev.Kind = EventKind.Lists; - ev.Tags.push(new Tag(["d", MUTE_LIST_TAG], ev.Tags.length)) + ev.Tags.push(new Tag(["d", Lists.Muted], ev.Tags.length)) keys.forEach(p => { ev.Tags.push(new Tag(["p", p], ev.Tags.length)) }) - // todo: public/private block - ev.Content = ""; + let content = "" + if (priv.length > 0) { + const ps = priv.map(p => ["p", p]) + const plaintext = JSON.stringify(ps) + if (hasNip07 && !privKey) { + content = await barierNip07(() => window.nostr.nip04.encrypt(pubKey, plaintext)); + } else if (privKey) { + content = await ev.EncryptData(plaintext, pubKey, privKey) + } + } + ev.Content = content; return await signEvent(ev); } }, @@ -294,7 +302,7 @@ const delay = (t: number) => { }); } -const barierNip07 = async (then: () => Promise) => { +export const barierNip07 = async (then: () => Promise) => { while (isNip07Busy) { await delay(10); } diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts index c5b99963..cfc8a2a5 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -1,27 +1,27 @@ -import Nostrich from "nostrich.jpg"; import { useEffect, useMemo } from "react"; import { useDispatch, useSelector } from "react-redux"; -import { HexKey, TaggedRawEvent } from "Nostr"; +import { makeNotification } from "Notifications"; +import { TaggedRawEvent, HexKey, Lists } from "Nostr"; import EventKind from "Nostr/EventKind"; +import Event from "Nostr/Event"; import { Subscriptions } from "Nostr/Subscriptions"; -import { addDirectMessage, addNotifications, setFollows, setRelays, setMuted } from "State/Login"; -import { RootState } from "State/Store"; +import { addDirectMessage, setFollows, setRelays, setMuted, setBlocked, sendNotification } from "State/Login"; +import type { RootState } from "State/Store"; import { db } from "Db"; +import { barierNip07 } from "Feed/EventPublisher"; import useSubscription from "Feed/Subscription"; -import { MUTE_LIST_TAG, getMutedKeys } from "Feed/MuteList"; +import { getMutedKeys, getNewest } from "Feed/MuteList"; import { mapEventToProfile, MetadataCache } from "Db/User"; -import { getDisplayName } from "Element/ProfileImage"; import useModeration from "Hooks/useModeration"; -import { MentionRegex } from "Const"; /** * Managed loading data for the current logged in user */ export default function useLoginFeed() { const dispatch = useDispatch(); - const { isMuted } = useModeration(); - const [pubKey, readNotifications] = useSelector(s => [s.login.publicKey, s.login.readNotifications]); + const { publicKey: pubKey, privateKey: privKey } = useSelector((s: RootState) => s.login); + const { isMuted } = useModeration(); const subMetadata = useMemo(() => { if (!pubKey) return null; @@ -53,7 +53,7 @@ export default function useLoginFeed() { sub.Id = "login:muted"; sub.Kinds = new Set([EventKind.Lists]); sub.Authors = new Set([pubKey]); - sub.DTags = new Set([MUTE_LIST_TAG]) + sub.DTag = Lists.Muted; sub.Limit = 1; return sub; @@ -103,7 +103,7 @@ export default function useLoginFeed() { acc.created = v.created; } return acc; - }, { created: 0, profile: null }); + }, { created: 0, profile: null as MetadataCache | null }); if (maxProfile.profile) { let existing = await db.users.get(maxProfile.profile.pubkey); if ((existing?.created ?? 0) < maxProfile.created) { @@ -111,75 +111,52 @@ export default function useLoginFeed() { } } })().catch(console.warn); - }, [metadataFeed.store]); + }, [dispatch, metadataFeed.store]); useEffect(() => { - let notifications = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote && !isMuted(a.pubkey)) - - if ("Notification" in window && Notification.permission === "granted") { - for (let nx of notifications.filter(a => (a.created_at * 1000) > readNotifications)) { - sendNotification(nx) - .catch(console.warn); + const replies = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote && !isMuted(a.pubkey)) + replies.forEach(nx => { + makeNotification(nx).then(notification => { + if (notification) { + // @ts-ignore + dispatch(sendNotification(notification)) } - } - - dispatch(addNotifications(notifications)); - }, [notificationFeed.store]); + }) + }) + }, [dispatch, notificationFeed.store]); useEffect(() => { - const ps = getMutedKeys(mutedFeed.store.notes) - dispatch(setMuted(ps)) - }, [mutedFeed.store]) + const muted = getMutedKeys(mutedFeed.store.notes) + dispatch(setMuted(muted)) + + const newest = getNewest(mutedFeed.store.notes) + if (newest && newest.content.length > 0 && pubKey) { + decryptBlocked(newest, pubKey, privKey).then((plaintext) => { + try { + const blocked = JSON.parse(plaintext) + const keys = blocked.filter((p:any) => p && p.length === 2 && p[0] === "p").map((p: any) => p[1]) + dispatch(setBlocked({ + keys, + createdAt: newest.created_at, + })) + } catch(error) { + console.debug("Couldn't parse JSON") + } + }).catch((error) => console.warn(error)) + } + }, [dispatch, mutedFeed.store]) useEffect(() => { let dms = dmsFeed.store.notes.filter(a => a.kind === EventKind.DirectMessage); dispatch(addDirectMessage(dms)); - }, [dmsFeed.store]); + }, [dispatch, dmsFeed.store]); } -async function makeNotification(ev: TaggedRawEvent) { - switch (ev.kind) { - case EventKind.TextNote: { - const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1]!)]); - const users = (await db.users.bulkGet(Array.from(pubkeys))).filter(a => a !== undefined).map(a => a!); - const fromUser = users.find(a => a?.pubkey === ev.pubkey); - const name = getDisplayName(fromUser, ev.pubkey); - const avatarUrl = (fromUser?.picture?.length ?? 0) === 0 ? Nostrich : fromUser?.picture; - return { - title: `Reply from ${name}`, - body: replaceTagsWithUser(ev, users).substring(0, 50), - icon: avatarUrl - } - } - } - return null; -} - -function replaceTagsWithUser(ev: TaggedRawEvent, users: MetadataCache[]) { - return ev.content.split(MentionRegex).map(match => { - let matchTag = match.match(/#\[(\d+)\]/); - if (matchTag && matchTag.length === 2) { - let idx = parseInt(matchTag[1]); - let ref = ev.tags[idx]; - if (ref && ref[0] === "p" && ref.length > 1) { - let u = users.find(a => a.pubkey === ref[1]); - return `@${getDisplayName(u, ref[1])}`; - } - } - return match; - }).join(); -} - -async function sendNotification(ev: TaggedRawEvent) { - let n = await makeNotification(ev); - if (n != null && Notification.permission === "granted") { - let worker = await navigator.serviceWorker.ready; - worker.showNotification(n.title, { - body: n.body, - icon: n.icon, - tag: "notification", - timestamp: ev.created_at * 1000, - vibrate: [500] - }); - } +async function decryptBlocked(raw: TaggedRawEvent, pubKey: HexKey, privKey?: HexKey) { + const ev = new Event(raw) + if (pubKey && privKey) { + return await ev.DecryptData(raw.content, privKey, pubKey) + } else { + return await barierNip07(() => window.nostr.nip04.decrypt(pubKey, raw.content)); + } } diff --git a/src/Feed/MuteList.ts b/src/Feed/MuteList.ts index f5fd2d7c..d18e0948 100644 --- a/src/Feed/MuteList.ts +++ b/src/Feed/MuteList.ts @@ -1,45 +1,41 @@ import { useMemo } from "react"; -import { useSelector } from "react-redux"; -import { HexKey, TaggedRawEvent } from "Nostr"; +import { HexKey, TaggedRawEvent, Lists } 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 = "p: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]); - sub.DTags = new Set([MUTE_LIST_TAG]) + sub.DTag = Lists.Muted; sub.Limit = 1; - return sub; }, [pubkey]); return useSubscription(sub); } -export function getMutedKeys(rawNotes: TaggedRawEvent[]): { createdAt: number, keys: HexKey[] } { +export function getNewest(rawNotes: TaggedRawEvent[]){ const notes = [...rawNotes] notes.sort((a, b) => a.created_at - b.created_at) - const newest = notes && notes[0] + if (notes.length > 0) { + return notes[0] + } +} + +export function getMutedKeys(rawNotes: TaggedRawEvent[]): { createdAt: number, keys: HexKey[] } { + const newest = getNewest(rawNotes) if (newest) { - const { tags } = newest - const mutedIndex = tags.findIndex(t => t[0] === "d" && t[1] === MUTE_LIST_TAG) - if (mutedIndex !== -1) { - return { - createdAt: newest.created_at, - keys: tags.slice(mutedIndex).filter(t => t[0] === "p").map(t => t[1]) - } - } + const { created_at, tags } = newest + const keys = tags.filter(t => t[0] === "p").map(t => t[1]) + return { + keys, + createdAt: created_at, + } } return { createdAt: 0, keys: [] } } diff --git a/src/Hooks/useModeration.tsx b/src/Hooks/useModeration.tsx index ab529491..a09316ad 100644 --- a/src/Hooks/useModeration.tsx +++ b/src/Hooks/useModeration.tsx @@ -3,26 +3,30 @@ 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"; +import { setMuted, setBlocked } from "State/Login"; export default function useModeration() { const dispatch = useDispatch() - const { muted } = useSelector((s: RootState) => s.login) + const { blocked, 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") - } + async function setMutedList(pub: HexKey[], priv: HexKey[]) { + try { + const ev = await publisher.muted(pub, priv) + console.debug(ev); + publisher.broadcast(ev) + } catch (error) { + console.debug("Couldn't change mute list") } + } function isMuted(id: HexKey) { - return muted.includes(id) + return muted.includes(id) || blocked.includes(id) + } + + function isBlocked(id: HexKey) { + return blocked.includes(id) } function unmute(id: HexKey) { @@ -31,26 +35,44 @@ export default function useModeration() { createdAt: new Date().getTime(), keys: newMuted })) - setMutedList(newMuted) + setMutedList(newMuted, blocked) + } + + function unblock(id: HexKey) { + const newBlocked = blocked.filter(p => p !== id) + dispatch(setBlocked({ + createdAt: new Date().getTime(), + keys: newBlocked + })) + setMutedList(muted, newBlocked) } function mute(id: HexKey) { - const newMuted = muted.concat([id]) - setMutedList(newMuted) + const newMuted = muted.includes(id) ? muted : muted.concat([id]) + setMutedList(newMuted, blocked) dispatch(setMuted({ createdAt: new Date().getTime(), keys: newMuted })) } + function block(id: HexKey) { + const newBlocked = blocked.includes(id) ? blocked : blocked.concat([id]) + setMutedList(muted, newBlocked) + dispatch(setBlocked({ + createdAt: new Date().getTime(), + keys: newBlocked + })) + } + function muteAll(ids: HexKey[]) { const newMuted = Array.from(new Set(muted.concat(ids))) - setMutedList(newMuted) + setMutedList(newMuted, blocked) dispatch(setMuted({ createdAt: new Date().getTime(), keys: newMuted })) } - return { muted, mute, muteAll, unmute, isMuted } + return { muted, mute, muteAll, unmute, isMuted, blocked, block, unblock, isBlocked } } diff --git a/src/Nostr/Event.ts b/src/Nostr/Event.ts index baf7aa3d..b19eb27a 100644 --- a/src/Nostr/Event.ts +++ b/src/Nostr/Event.ts @@ -139,26 +139,33 @@ export default class Event { } /** - * Encrypt the message content in place + * Encrypt the given message content */ - async EncryptDmForPubkey(pubkey: HexKey, privkey: HexKey) { + async EncryptData(content: string, pubkey: HexKey, privkey: HexKey) { let key = await this._GetDmSharedKey(pubkey, privkey); let iv = window.crypto.getRandomValues(new Uint8Array(16)); - let data = new TextEncoder().encode(this.Content); + let data = new TextEncoder().encode(content); let result = await window.crypto.subtle.encrypt({ name: "AES-CBC", iv: iv }, key, data); let uData = new Uint8Array(result); - this.Content = `${base64.encode(uData, 0, result.byteLength)}?iv=${base64.encode(iv, 0, 16)}`; + return `${base64.encode(uData, 0, result.byteLength)}?iv=${base64.encode(iv, 0, 16)}`; } /** - * Decrypt the content of this message in place + * Encrypt the message content in place */ - async DecryptDm(privkey: HexKey, pubkey: HexKey) { + async EncryptDmForPubkey(pubkey: HexKey, privkey: HexKey) { + this.Content = await this.EncryptData(this.Content, pubkey, privkey); + } + + /** + * Decrypt the content of the message + */ + async DecryptData(cyphertext: string, privkey: HexKey, pubkey: HexKey) { let key = await this._GetDmSharedKey(pubkey, privkey); - let cSplit = this.Content.split("?iv="); + let cSplit = cyphertext.split("?iv="); let data = new Uint8Array(base64.length(cSplit[0])); base64.decode(cSplit[0], data, 0); @@ -169,7 +176,14 @@ export default class Event { name: "AES-CBC", iv: iv }, key, data); - this.Content = new TextDecoder().decode(result); + return new TextDecoder().decode(result); + } + + /** + * Decrypt the content of this message in place + */ + async DecryptDm(privkey: HexKey, pubkey: HexKey) { + this.Content = await this.DecryptData(this.Content, privkey, pubkey) } async _GetDmSharedKey(pubkey: HexKey, privkey: HexKey) { @@ -177,4 +191,4 @@ export default class Event { let sharedX = sharedPoint.slice(1, 33); return await window.crypto.subtle.importKey("raw", sharedX, { name: "AES-CBC" }, false, ["encrypt", "decrypt"]) } -} \ No newline at end of file +} diff --git a/src/Nostr/Subscriptions.ts b/src/Nostr/Subscriptions.ts index 47854f8b..bc6fe068 100644 --- a/src/Nostr/Subscriptions.ts +++ b/src/Nostr/Subscriptions.ts @@ -43,9 +43,9 @@ export class Subscriptions { HashTags?: Set; /** - * A list of "d" tags to search + * A "d" tag to search */ - DTags?: Set; + DTag?: string; /** * a timestamp, events must be newer than this to pass @@ -94,7 +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.DTag = sub?.["#d"] ? sub["#d"] : undefined; this.Since = sub?.since ?? undefined; this.Until = sub?.until ?? undefined; this.Limit = sub?.limit ?? undefined; @@ -139,8 +139,8 @@ export class Subscriptions { if (this.HashTags) { ret["#t"] = Array.from(this.HashTags); } - if (this.DTags) { - ret["#d"] = Array.from(this.DTags); + if (this.DTag) { + ret["#d"] = this.DTag; } if (this.Since !== null) { ret.since = this.Since; diff --git a/src/Nostr/Tag.ts b/src/Nostr/Tag.ts index 6ee98eec..fb961ae9 100644 --- a/src/Nostr/Tag.ts +++ b/src/Nostr/Tag.ts @@ -67,11 +67,11 @@ export default class Tag { return ["t", this.Hashtag!]; } case "d": { - return ["t", this.DTag!]; + return ["d", this.DTag!]; } default: { return this.Original; } } } -} \ No newline at end of file +} diff --git a/src/Nostr/index.ts b/src/Nostr/index.ts index 3b016337..1d8c4384 100644 --- a/src/Nostr/index.ts +++ b/src/Nostr/index.ts @@ -35,7 +35,7 @@ export type RawReqFilter = { "#e"?: u256[], "#p"?: u256[], "#t"?: string[], - "#d"?: string[], + "#d"?: string, since?: number, until?: number, limit?: number @@ -55,3 +55,10 @@ export type UserMetadata = { lud06?: string, lud16?: string } + +/** + * NIP-51 list types + */ +export enum Lists { + Muted = "mute" +} diff --git a/src/Notifications.ts b/src/Notifications.ts new file mode 100644 index 00000000..c92b6040 --- /dev/null +++ b/src/Notifications.ts @@ -0,0 +1,43 @@ +import Nostrich from "nostrich.jpg"; + +import { TaggedRawEvent } from "Nostr"; +import EventKind from "Nostr/EventKind"; +import type { NotificationRequest } from "State/Login"; +import { db } from "Db"; +import { MetadataCache } from "Db/User"; +import { getDisplayName } from "Element/ProfileImage"; +import { MentionRegex } from "Const"; + +export async function makeNotification(ev: TaggedRawEvent): Promise { + switch (ev.kind) { + case EventKind.TextNote: { + const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1]!)]); + const users = (await db.users.bulkGet(Array.from(pubkeys))).filter(a => a !== undefined).map(a => a!); + const fromUser = users.find(a => a?.pubkey === ev.pubkey); + const name = getDisplayName(fromUser, ev.pubkey); + const avatarUrl = (fromUser?.picture?.length ?? 0) === 0 ? Nostrich : fromUser?.picture; + return { + title: `Reply from ${name}`, + body: replaceTagsWithUser(ev, users).substring(0, 50), + icon: avatarUrl, + timestamp: ev.created_at * 1000, + } + } + } + return null; +} + +function replaceTagsWithUser(ev: TaggedRawEvent, users: MetadataCache[]) { + return ev.content.split(MentionRegex).map(match => { + let matchTag = match.match(/#\[(\d+)\]/); + if (matchTag && matchTag.length === 2) { + let idx = parseInt(matchTag[1]); + let ref = ev.tags[idx]; + if (ref && ref[0] === "p" && ref.length > 1) { + let u = users.find(a => a.pubkey === ref[1]); + return `@${getDisplayName(u, ref[1])}`; + } + } + return match; + }).join(); +} diff --git a/src/Pages/Login.tsx b/src/Pages/Login.tsx index 0768af51..d32ce7c8 100644 --- a/src/Pages/Login.tsx +++ b/src/Pages/Login.tsx @@ -20,7 +20,7 @@ export default function LoginPage() { if (publicKey) { navigate("/"); } - }, [publicKey]); + }, [publicKey, navigate]); async function getNip05PubKey(addr: string) { let [username, domain] = addr.split("@"); @@ -32,7 +32,7 @@ export default function LoginPage() { return pKey; } } - throw "User key not found" + throw new Error("User key not found") } async function doLogin() { @@ -43,7 +43,7 @@ export default function LoginPage() { if (secp.utils.isValidPrivateKey(hexKey)) { dispatch(setPrivateKey(hexKey)); } else { - throw "INVALID PRIVATE KEY"; + throw new Error("INVALID PRIVATE KEY"); } } else if (key.startsWith("npub")) { let hexKey = bech32ToHex(key); @@ -55,7 +55,7 @@ export default function LoginPage() { if (secp.utils.isValidPrivateKey(key)) { dispatch(setPrivateKey(key)); } else { - throw "INVALID PRIVATE KEY"; + throw new Error("INVALID PRIVATE KEY"); } } } catch (e) { diff --git a/src/Pages/MessagesPage.tsx b/src/Pages/MessagesPage.tsx index 9895e067..01c6b392 100644 --- a/src/Pages/MessagesPage.tsx +++ b/src/Pages/MessagesPage.tsx @@ -21,11 +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 { isMuted } = useModeration(); const chats = useMemo(() => { - return extractChats(dms.filter(a => !isMuted(a.pubkey)), myPubKey!); - }, [dms, myPubKey, dmInteraction, muted]); + return extractChats(dms.filter(a => !isMuted(a.pubkey)), myPubKey!) + }, [dms, myPubKey, dmInteraction]); function noteToSelf(chat: DmChat) { return ( @@ -93,7 +93,7 @@ export function isToSelf(e: RawEvent, pk: HexKey) { } export function dmsInChat(dms: RawEvent[], pk: HexKey) { - return dms.filter(a => a.pubkey === pk || dmTo(a) == pk); + return dms.filter(a => a.pubkey === pk || dmTo(a) === pk); } export function totalUnread(dms: RawEvent[], myPubKey: HexKey) { diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index 3f80d216..adddf75c 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -18,6 +18,7 @@ import Nip05 from "Element/Nip05"; import Copy from "Element/Copy"; import ProfilePreview from "Element/ProfilePreview"; import FollowersList from "Element/FollowersList"; +import BlockList from "Element/BlockList"; import MutedList from "Element/MutedList"; import FollowsList from "Element/FollowsList"; import { RootState } from "State/Store"; @@ -123,7 +124,7 @@ export default function ProfilePage() { return } case ProfileTab.Muted: { - return + return isMe ? : } } } diff --git a/src/Pages/settings/Index.tsx b/src/Pages/settings/Index.tsx index 5fc6ae1b..d6b9877d 100644 --- a/src/Pages/settings/Index.tsx +++ b/src/Pages/settings/Index.tsx @@ -1,12 +1,23 @@ -import { faCircleDollarToSlot, faGear, faPlug, faUser } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useNavigate } from "react-router-dom"; import "./Index.css"; +import { useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; +import { faRightFromBracket, faCircleDollarToSlot, faGear, faPlug, faUser } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { logout } from "State/Login"; + const SettingsIndex = () => { + const dispatch = useDispatch(); const navigate = useNavigate(); + function handleLogout() { + dispatch(logout()) + navigate("/") + } + return ( + <>
navigate("profile")}> @@ -24,7 +35,12 @@ const SettingsIndex = () => { Donate
+
+ + Log Out +
+ ) } diff --git a/src/State/Login.ts b/src/State/Login.ts index cc6dec7f..80f904c9 100644 --- a/src/State/Login.ts +++ b/src/State/Login.ts @@ -3,12 +3,20 @@ import * as secp from '@noble/secp256k1'; import { DefaultRelays } from 'Const'; import { HexKey, TaggedRawEvent } from 'Nostr'; import { RelaySettings } from 'Nostr/Connection'; +import type { AppDispatch, RootState } from "State/Store"; const PrivateKeyItem = "secret"; const PublicKeyItem = "pubkey"; const NotificationsReadItem = "notifications-read"; const UserPreferencesKey = "preferences"; +export interface NotificationRequest { + title: string + body: string + icon: string + timestamp: number +} + export interface UserPreferences { /** * Enable reactions / reposts / zaps @@ -87,6 +95,11 @@ export interface LoginStore { */ latestMuted: number, + /** + * A list of pubkeys this user has muted privately + */ + blocked: HexKey[], + /** * Notifications for this login session */ @@ -122,6 +135,7 @@ const InitState = { follows: [], latestFollows: 0, muted: [], + blocked: [], latestMuted: 0, notifications: [], readNotifications: new Date().getTime(), @@ -238,49 +252,26 @@ const LoginSlice = createSlice({ }, setMuted(state, action: PayloadAction<{createdAt: number, keys: HexKey[]}>) { const { createdAt, keys } = action.payload - if (createdAt > state.latestMuted) { + if (createdAt >= state.latestMuted) { const muted = new Set([...keys]) state.muted = Array.from(muted) state.latestMuted = createdAt } }, - addNotifications: (state, action: PayloadAction) => { - let n = action.payload; - if (!Array.isArray(n)) { - n = [n]; - } - - let didChange = false; - for (let x of n) { - if (!state.notifications.some(a => a.id === x.id)) { - state.notifications.push(x); - didChange = true; - } - } - if (didChange) { - state.notifications = [ - ...state.notifications - ]; - } + setBlocked(state, action: PayloadAction<{createdAt: number, keys: HexKey[]}>) { + const { createdAt, keys } = action.payload + if (createdAt >= state.latestMuted) { + const blocked = new Set([...keys]) + state.blocked = Array.from(blocked) + state.latestMuted = createdAt + } }, addDirectMessage: (state, action: PayloadAction>) => { let n = action.payload; if (!Array.isArray(n)) { n = [n]; } - - let didChange = false; - for (let x of n) { - if (!state.dms.some(a => a.id === x.id)) { - state.dms.push(x); - didChange = true; - } - } - if (didChange) { - state.dms = [ - ...state.dms - ]; - } + state.dms = n; }, incDmInteraction: (state) => { state.dmInteraction += 1; @@ -309,12 +300,36 @@ export const { setRelays, removeRelay, setFollows, - addNotifications, setMuted, + setBlocked, addDirectMessage, incDmInteraction, logout, markNotificationsRead, setPreferences, } = LoginSlice.actions; + +export function sendNotification({ title, body, icon, timestamp }: NotificationRequest) { + return async (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState() + const { readNotifications } = state.login + const hasPermission = "Notification" in window && Notification.permission === "granted" + const shouldShowNotification = hasPermission && timestamp > readNotifications + if (shouldShowNotification) { + try { + let worker = await navigator.serviceWorker.ready; + worker.showNotification(title, { + tag: "notification", + vibrate: [500], + body, + icon, + timestamp, + }); + } catch (error) { + console.warn(error) + } + } + } +} + export const reducer = LoginSlice.reducer; diff --git a/src/index.css b/src/index.css index 7968fb22..afe45b1b 100644 --- a/src/index.css +++ b/src/index.css @@ -397,27 +397,6 @@ body.scroll-lock { margin-right: auto; } -.tabs { - display: flex; - align-content: center; - text-align: center; - margin: 10px 0; - overflow-x: auto; -} - -.tabs>div { - margin-right: 10px; - cursor: pointer; -} - -.tabs>div:last-child { - margin: 0; -} - -.tabs .active { - font-weight: 700; -} - .error { color: var(--error); } @@ -431,12 +410,27 @@ body.scroll-lock { } .tabs { - padding: 0; - align-items: center; - justify-content: flex-start; + display: flex; + align-content: center; + text-align: center; + margin-top: 10px; + overflow-x: auto; margin-bottom: 16px; } +.tabs > * { + margin-right: 10px; + cursor: pointer; +} + +.tabs > *:last-child { + margin: 0; +} + +.tabs .active { + font-weight: 700; +} + .tab { border-bottom: 1px solid var(--gray-secondary); font-weight: 700; From e245a3931bef977f69c1129a536db08cf5a31a21 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sat, 28 Jan 2023 12:57:09 +0100 Subject: [PATCH 09/13] fix: remove unintended change --- src/State/Login.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/State/Login.ts b/src/State/Login.ts index 80f904c9..a7fac58c 100644 --- a/src/State/Login.ts +++ b/src/State/Login.ts @@ -271,7 +271,20 @@ const LoginSlice = createSlice({ if (!Array.isArray(n)) { n = [n]; } - state.dms = n; + + let didChange = false; + for (let x of n) { + if (!state.dms.some(a => a.id === x.id)) { + state.dms.push(x); + didChange = true; + } + } + + if (didChange) { + state.dms = [ + ...state.dms + ]; + } }, incDmInteraction: (state) => { state.dmInteraction += 1; From 01c15c30a47c1982e6bc22a64b3be3a0f4958ab6 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sat, 28 Jan 2023 19:30:39 +0100 Subject: [PATCH 10/13] separate mute and blocked tabs --- src/Element/BlockList.tsx | 30 +++++++++++++++++++++--------- src/Pages/ProfilePage.tsx | 17 ++++++++++++----- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/Element/BlockList.tsx b/src/Element/BlockList.tsx index 7125f0f5..bcf91d5e 100644 --- a/src/Element/BlockList.tsx +++ b/src/Element/BlockList.tsx @@ -8,20 +8,32 @@ import ProfilePreview from "Element/ProfilePreview"; import useMutedFeed, { getMuted } from "Feed/MuteList"; import useModeration from "Hooks/useModeration"; -export default function BlockList() { +interface BlockListProps { + variant: "muted" | "blocked" +} + +export default function BlockList({ variant }: BlockListProps) { const { publicKey } = useSelector((s: RootState) => s.login) const { blocked, muted } = useModeration(); return (
-

Muted ({muted.length})

- {muted.map(a => { - return } pubkey={a} options={{ about: false }} key={a} /> - })} -

Blocked ({blocked.length})

- {blocked.map(a => { - return } pubkey={a} options={{ about: false }} key={a} /> - })} + {variant === "muted" && ( + <> +

{muted.length} muted

+ {muted.map(a => { + return } pubkey={a} options={{ about: false }} key={a} /> + })} + + )} + {variant === "blocked" && ( + <> +

{blocked.length} blocked

+ {blocked.map(a => { + return } pubkey={a} options={{ about: false }} key={a} /> + })} + + )}
) } diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index adddf75c..3724f55a 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -30,7 +30,8 @@ enum ProfileTab { Reactions = "Reactions", Followers = "Followers", Follows = "Follows", - Muted = "Muted" + Muted = "Muted", + Blocked = "Blocked" }; export default function ProfilePage() { @@ -124,7 +125,10 @@ export default function ProfilePage() { return } case ProfileTab.Muted: { - return isMe ? : + return isMe ? : + } + case ProfileTab.Blocked: { + return isMe ? : null } } } @@ -165,6 +169,10 @@ export default function ProfilePage() { ) } + function renderTab(v: ProfileTab) { + return
setTab(v)}>{v}
+ } + return ( <>
@@ -175,9 +183,8 @@ export default function ProfilePage() {
- {[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Muted].map(v => { - return
setTab(v)}>{v}
- })} + {[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Muted].map(renderTab)} + {isMe && renderTab(ProfileTab.Blocked)}
{tabContent()} From 152970288d0cec9b1b91f83e9578478d0e98948e Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sat, 28 Jan 2023 19:51:08 +0100 Subject: [PATCH 11/13] hidden note styles --- src/Element/Note.css | 13 +++++++++++++ src/Element/Note.tsx | 2 +- src/Element/NoteReaction.tsx | 6 ++---- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/Element/Note.css b/src/Element/Note.css index e58114dd..0d1c87ee 100644 --- a/src/Element/Note.css +++ b/src/Element/Note.css @@ -181,3 +181,16 @@ .light .note.active>.footer>.reaction-pill.reacted { color: var(--highlight); } + +.hidden-note .header { + display: flex; + align-items: center; +} + +.card.note.hidden-note { + min-height: unset; +} + +.hidden-note button { + max-height: 30px; +} diff --git a/src/Element/Note.tsx b/src/Element/Note.tsx index 2094424c..e6ff52eb 100644 --- a/src/Element/Note.tsx +++ b/src/Element/Note.tsx @@ -30,7 +30,7 @@ export interface NoteProps { const HiddenNote = ({ children }: any) => { const [show, setShow] = useState(false) return show ? children : ( -
+

This note was hidden because of your moderation settings diff --git a/src/Element/NoteReaction.tsx b/src/Element/NoteReaction.tsx index 771b72a8..2f7db624 100644 --- a/src/Element/NoteReaction.tsx +++ b/src/Element/NoteReaction.tsx @@ -20,8 +20,6 @@ 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 pRef = data?.tags.find((a: any) => a[0] === "p")?.at(1); - const isRefMuted = pRef && isMuted(pRef) const refEvent = useMemo(() => { if (ev) { @@ -53,13 +51,13 @@ export default function NoteReaction(props: NoteReactionProps) { } const root = extractRoot(); + const isOpMuted = root && isMuted(root.pubkey) const opt = { showHeader: ev?.Kind === EventKind.Repost, showFooter: false, }; - const isOpMuted = root && isMuted(root.pubkey) - return isOpMuted || isRefMuted ? null : ( + return isOpMuted ? null : (

From ddec154fcbb8da27aaf009cdf7a0b75f71911114 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sat, 28 Jan 2023 20:05:11 +0100 Subject: [PATCH 12/13] shorten sub id --- src/Feed/MuteList.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Feed/MuteList.ts b/src/Feed/MuteList.ts index d18e0948..79eb14d2 100644 --- a/src/Feed/MuteList.ts +++ b/src/Feed/MuteList.ts @@ -8,7 +8,7 @@ import useSubscription, { NoteStore } from "Feed/Subscription"; export default function useMutedFeed(pubkey: HexKey) { const sub = useMemo(() => { let sub = new Subscriptions(); - sub.Id = `muted:${pubkey}`; + sub.Id = `muted:${pubkey.slice(0, 12)}`; sub.Kinds = new Set([EventKind.Lists]); sub.Authors = new Set([pubkey]); sub.DTag = Lists.Muted; From 5e0b36f16514ecbaa8678ff7af8afecb6686dd2d Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sat, 28 Jan 2023 22:09:02 +0100 Subject: [PATCH 13/13] ignore moderation when viewing profile --- src/Element/Note.tsx | 5 +++-- src/Element/Timeline.tsx | 7 ++++--- src/Pages/ProfilePage.tsx | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Element/Note.tsx b/src/Element/Note.tsx index e6ff52eb..f31d658e 100644 --- a/src/Element/Note.tsx +++ b/src/Element/Note.tsx @@ -19,6 +19,7 @@ export interface NoteProps { isThread?: boolean, related: TaggedRawEvent[], highlight?: boolean, + ignoreModeration?: boolean, options?: { showHeader?: boolean, showTime?: boolean, @@ -46,7 +47,7 @@ const HiddenNote = ({ children }: any) => { export default function Note(props: NoteProps) { const navigate = useNavigate(); - const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent } = props + const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false} = props const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]); const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]); const users = useProfile(pubKeys); @@ -176,5 +177,5 @@ export default function Note(props: NoteProps) {
) - return isOpMuted ? {note} : note + return !ignoreModeration && isOpMuted ? {note} : note } diff --git a/src/Element/Timeline.tsx b/src/Element/Timeline.tsx index abf5ba56..0324309f 100644 --- a/src/Element/Timeline.tsx +++ b/src/Element/Timeline.tsx @@ -15,19 +15,20 @@ export interface TimelineProps { postsOnly: boolean, subject: TimelineSubject, method: "TIME_RANGE" | "LIMIT_UNTIL" + ignoreModeration?: boolean } /** * A list of notes by pubkeys */ -export default function Timeline({ subject, postsOnly = false, method }: TimelineProps) { +export default function Timeline({ subject, postsOnly = false, method, ignoreModeration = false }: TimelineProps) { const { muted, isMuted } = useModeration(); 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).filter(a => !isMuted(a.pubkey)); + return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true).filter(a => ignoreModeration || !isMuted(a.pubkey)); }, [postsOnly, muted]); const mainFeed = useMemo(() => { @@ -41,7 +42,7 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin function eventElement(e: TaggedRawEvent) { switch (e.kind) { case EventKind.TextNote: { - return + return } case EventKind.Reaction: case EventKind.Repost: { diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index 3724f55a..d86b9be3 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -108,7 +108,7 @@ export default function ProfilePage() { function tabContent() { switch (tab) { case ProfileTab.Notes: - return ; + return ; case ProfileTab.Follows: { if (isMe) { return (