diff --git a/README.md b/README.md index eb77f2b10..ffd7801c7 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/BlockButton.tsx b/src/Element/BlockButton.tsx new file mode 100644 index 000000000..f2cd54267 --- /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 000000000..bcf91d5e9 --- /dev/null +++ b/src/Element/BlockList.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 BlockButton from "Element/BlockButton"; +import ProfilePreview from "Element/ProfilePreview"; +import useMutedFeed, { getMuted } from "Feed/MuteList"; +import useModeration from "Hooks/useModeration"; + +interface BlockListProps { + variant: "muted" | "blocked" +} + +export default function BlockList({ variant }: BlockListProps) { + const { publicKey } = useSelector((s: RootState) => s.login) + const { blocked, muted } = useModeration(); + + return ( +
+ {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/Element/Invoice.tsx b/src/Element/Invoice.tsx index ffeae1f49..80ad2cb5d 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 000000000..bb25af723 --- /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 000000000..8d3447515 --- /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 000000000..23657ab6c --- /dev/null +++ b/src/Element/MutedList.tsx @@ -0,0 +1,38 @@ +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 { muted, isMuted, mute, unmute, muteAll } = useModeration(); + const feed = useMutedFeed(pubkey) + const pubkeys = useMemo(() => { + return 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.css b/src/Element/Note.css index e58114dda..0d1c87eeb 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 68a542887..f31d658ed 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"; @@ -12,12 +12,14 @@ 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, isThread?: boolean, related: TaggedRawEvent[], highlight?: boolean, + ignoreModeration?: boolean, options?: { showHeader?: boolean, showTime?: boolean, @@ -26,13 +28,32 @@ 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 + 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); const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]); + const { isMuted } = useModeration() + const isOpMuted = isMuted(ev.PubKey) const { ref, inView } = useInView({ triggerOnce: true }); const options = { @@ -150,9 +171,11 @@ export default function Note(props: NoteProps) { ) } - return ( -
- {content()} -
+ const note = ( +
+ {content()} +
) + + return !ignoreModeration && isOpMuted ? {note} : note } diff --git a/src/Element/NoteFooter.tsx b/src/Element/NoteFooter.tsx index a715e85b2..42db172ef 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, faBan } 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, block } = useModeration(); const prefs = useSelector(s => s.login.preferences); const author = useProfile(ev.RootPubKey)?.get(ev.RootPubKey); const publisher = useEventPublisher(); @@ -169,6 +171,14 @@ export default function NoteFooter(props: NoteFooterProps) { Copy ID + mute(ev.PubKey)}> + + Mute + + block(ev.PubKey)}> + + Block + {prefs.showDebugMenus && ( copyEvent()}> diff --git a/src/Element/NoteReaction.tsx b/src/Element/NoteReaction.tsx index dc5bd62fa..2f7db6241 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) { @@ -49,12 +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, }; - return ( + return isOpMuted ? null : (
diff --git a/src/Element/Text.css b/src/Element/Text.css index 0b13975fd..8903a4297 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 89e04cbf9..0324309f9 100644 --- a/src/Element/Timeline.tsx +++ b/src/Element/Timeline.tsx @@ -1,44 +1,48 @@ import "./Timeline.css"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faForward } from "@fortawesome/free-solid-svg-icons"; import { useCallback, useMemo } from "react"; + 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 useModeration from "Hooks/useModeration"; 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); - }, [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 => ignoreModeration || !isMuted(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)); + return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id)) }, [latest, mainFeed, filterPosts]); function eventElement(e: TaggedRawEvent) { switch (e.kind) { case EventKind.TextNote: { - return + return } case EventKind.Reaction: case EventKind.Repost: { diff --git a/src/Feed/EventPublisher.ts b/src/Feed/EventPublisher.ts index 125cf17b0..c4d44a363 100644 --- a/src/Feed/EventPublisher.ts +++ b/src/Feed/EventPublisher.ts @@ -1,10 +1,11 @@ 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 { HexKey, RawEvent, u256, UserMetadata, Lists } from "Nostr"; import { bech32ToHex } from "Util" import { DefaultRelays, HashtagRegex } from "Const"; @@ -95,6 +96,28 @@ export default function useEventPublisher() { } } }, + muted: async (keys: HexKey[], priv: HexKey[]) => { + if (pubKey) { + let ev = NEvent.ForPubKey(pubKey); + ev.Kind = EventKind.Lists; + ev.Tags.push(new Tag(["d", Lists.Muted], ev.Tags.length)) + keys.forEach(p => { + ev.Tags.push(new Tag(["p", p], ev.Tags.length)) + }) + 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); + } + }, metadata: async (obj: UserMetadata) => { if (pubKey) { let ev = NEvent.ForPubKey(pubKey); @@ -279,7 +302,7 @@ const delay = (t: number) => { }); } -const barierNip07 = async (then: () => Promise) => { +export const barierNip07 = async (then: () => Promise) => { while (isNip07Busy) { await delay(10); } @@ -289,4 +312,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 7c4521f2e..cfc8a2a50 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -1,24 +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 } 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 { getMutedKeys, getNewest } from "Feed/MuteList"; import { mapEventToProfile, MetadataCache } from "Db/User"; -import { getDisplayName } from "Element/ProfileImage"; -import { MentionRegex } from "Const"; +import useModeration from "Hooks/useModeration"; /** * Managed loading data for the current logged in user */ export default function useLoginFeed() { const dispatch = useDispatch(); - 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; @@ -27,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]); @@ -42,6 +46,19 @@ 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]); + sub.DTag = Lists.Muted; + sub.Limit = 1; + + return sub; + }, [pubKey]); + const subDms = useMemo(() => { if (!pubKey) return null; @@ -61,6 +78,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); @@ -75,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 () => { @@ -85,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) { @@ -93,70 +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); - - 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, notificationFeed.store]); - dispatch(addNotifications(notifications)); - }, [notificationFeed.store]); + useEffect(() => { + 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; +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)); + } } - -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] - }); - } -} \ No newline at end of file diff --git a/src/Feed/MuteList.ts b/src/Feed/MuteList.ts new file mode 100644 index 000000000..79eb14d29 --- /dev/null +++ b/src/Feed/MuteList.ts @@ -0,0 +1,46 @@ +import { useMemo } from "react"; + +import { HexKey, TaggedRawEvent, Lists } from "Nostr"; +import EventKind from "Nostr/EventKind"; +import { Subscriptions } from "Nostr/Subscriptions"; +import useSubscription, { NoteStore } from "Feed/Subscription"; + +export default function useMutedFeed(pubkey: HexKey) { + const sub = useMemo(() => { + let sub = new Subscriptions(); + sub.Id = `muted:${pubkey.slice(0, 12)}`; + sub.Kinds = new Set([EventKind.Lists]); + sub.Authors = new Set([pubkey]); + sub.DTag = Lists.Muted; + sub.Limit = 1; + return sub; + }, [pubkey]); + + return useSubscription(sub); +} + +export function getNewest(rawNotes: TaggedRawEvent[]){ + const notes = [...rawNotes] + notes.sort((a, b) => a.created_at - b.created_at) + if (notes.length > 0) { + return notes[0] + } +} + +export function getMutedKeys(rawNotes: TaggedRawEvent[]): { createdAt: number, keys: HexKey[] } { + const newest = getNewest(rawNotes) + if (newest) { + 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: [] } +} + +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 000000000..a09316add --- /dev/null +++ b/src/Hooks/useModeration.tsx @@ -0,0 +1,78 @@ +import { useSelector, useDispatch } from "react-redux"; + +import type { RootState } from "State/Store"; +import { HexKey } from "Nostr"; +import useEventPublisher from "Feed/EventPublisher"; +import { setMuted, setBlocked } from "State/Login"; + + +export default function useModeration() { + const dispatch = useDispatch() + const { blocked, muted } = useSelector((s: RootState) => s.login) + const publisher = useEventPublisher() + + 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) || blocked.includes(id) + } + + function isBlocked(id: HexKey) { + return blocked.includes(id) + } + + function unmute(id: HexKey) { + const newMuted = muted.filter(p => p !== id) + dispatch(setMuted({ + createdAt: new Date().getTime(), + keys: 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.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, blocked) + dispatch(setMuted({ + createdAt: new Date().getTime(), + keys: newMuted + })) + } + + return { muted, mute, muteAll, unmute, isMuted, blocked, block, unblock, isBlocked } +} diff --git a/src/Nostr/Event.ts b/src/Nostr/Event.ts index baf7aa3dd..b19eb27ab 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/EventKind.ts b/src/Nostr/EventKind.ts index d12012b98..b8a0de042 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 b9af235ae..bc6fe0682 100644 --- a/src/Nostr/Subscriptions.ts +++ b/src/Nostr/Subscriptions.ts @@ -42,6 +42,11 @@ export class Subscriptions { */ HashTags?: Set; + /** + * A "d" tag to search + */ + DTag?: string; + /** * 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.DTag = sub?.["#d"] ? 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.DTag) { + ret["#d"] = this.DTag; + } 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/Tag.ts b/src/Nostr/Tag.ts index e10af8617..fb961ae91 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,9 +66,12 @@ export default class Tag { case "t": { return ["t", this.Hashtag!]; } + case "d": { + 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 cac6946e8..1d8c43847 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,11 @@ export type UserMetadata = { nip05?: string, lud06?: string, lud16?: string -} \ No newline at end of file +} + +/** + * NIP-51 list types + */ +export enum Lists { + Muted = "mute" +} diff --git a/src/Notifications.ts b/src/Notifications.ts new file mode 100644 index 000000000..c92b60400 --- /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 0768af518..d32ce7c8b 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 f0936fb4d..01c6b392d 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,9 +21,10 @@ 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 { isMuted } = useModeration(); const chats = useMemo(() => { - return extractChats(dms, myPubKey!); + return extractChats(dms.filter(a => !isMuted(a.pubkey)), myPubKey!) }, [dms, myPubKey, dmInteraction]); function noteToSelf(chat: DmChat) { @@ -91,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 8a04440f9..d86b9be3a 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,8 @@ 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"; import { HexKey } from "Nostr"; @@ -26,7 +29,9 @@ enum ProfileTab { Notes = "Notes", Reactions = "Reactions", Followers = "Followers", - Follows = "Follows" + Follows = "Follows", + Muted = "Muted", + Blocked = "Blocked" }; export default function ProfilePage() { @@ -103,7 +108,7 @@ export default function ProfilePage() { function tabContent() { switch (tab) { case ProfileTab.Notes: - return ; + return ; case ProfileTab.Follows: { if (isMe) { return ( @@ -119,6 +124,12 @@ export default function ProfilePage() { case ProfileTab.Followers: { return } + case ProfileTab.Muted: { + return isMe ? : + } + case ProfileTab.Blocked: { + return isMe ? : null + } } } @@ -136,9 +147,12 @@ export default function ProfilePage() { {username()}
{isMe ? ( + <> + + ) : ( !loggedOut && ( <> @@ -155,6 +169,10 @@ export default function ProfilePage() { ) } + function renderTab(v: ProfileTab) { + return
setTab(v)}>{v}
+ } + return ( <>
@@ -165,9 +183,8 @@ export default function ProfilePage() {
- {[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(v => { - return
setTab(v)}>{v}
- })} + {[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Muted].map(renderTab)} + {isMe && renderTab(ProfileTab.Blocked)}
{tabContent()} diff --git a/src/Pages/settings/Index.tsx b/src/Pages/settings/Index.tsx index 5fc6ae1b0..d6b9877d7 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/Pages/settings/Profile.tsx b/src/Pages/settings/Profile.tsx index bd96889fa..5e95d27eb 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 ca009db47..a7fac58c0 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 @@ -72,6 +80,26 @@ export interface LoginStore { */ follows: HexKey[], + /** + * Newest relay list timestamp + */ + latestFollows: number, + + /** + * A list of pubkeys this user has muted + */ + muted: HexKey[], + + /** + * Last seen mute list event timestamp + */ + latestMuted: number, + + /** + * A list of pubkeys this user has muted privately + */ + blocked: HexKey[], + /** * Notifications for this login session */ @@ -105,6 +133,10 @@ const InitState = { relays: {}, latestRelays: 0, follows: [], + latestFollows: 0, + muted: [], + blocked: [], + latestMuted: 0, notifications: [], readNotifications: new Date().getTime(), dms: [], @@ -124,6 +156,11 @@ export interface SetRelaysPayload { createdAt: number }; +export interface SetFollowsPayload { + keys: HexKey[] + createdAt: number +}; + const LoginSlice = createSlice({ name: "Login", initialState: InitState, @@ -192,9 +229,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) { @@ -205,26 +247,24 @@ const LoginSlice = createSlice({ } if (changes) { state.follows = Array.from(existing); + state.latestFollows = 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 - ]; - } + 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.latestMuted = createdAt + } + }, + 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; @@ -239,6 +279,7 @@ const LoginSlice = createSlice({ didChange = true; } } + if (didChange) { state.dms = [ ...state.dms @@ -272,11 +313,36 @@ export const { setRelays, removeRelay, setFollows, - addNotifications, + setMuted, + setBlocked, addDirectMessage, incDmInteraction, logout, markNotificationsRead, - setPreferences + setPreferences, } = LoginSlice.actions; -export const reducer = LoginSlice.reducer; \ No newline at end of file + +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 3b953bcb6..afe45b1b8 100644 --- a/src/index.css +++ b/src/index.css @@ -124,10 +124,15 @@ button:disabled { cursor: not-allowed; color: var(--gray); } -.light button:disabled { + +.light button.transparent { color: var(--font-color); } +.light button:disabled { + color: var(--font-secondary-color); + border-color: var(--font-secondary-color); +} button:hover { background-color: var(--font-color); @@ -392,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); } @@ -426,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;