From 456aa5fb797e40056583d34ce2c32422cfd19b94 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 27 Jan 2023 22:10:14 +0100 Subject: [PATCH] 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 0000000..f2cd542 --- /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 0000000..7125f0f --- /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 f4c2182..23657ab 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 82a67fd..2094424 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 72b7360..42db172 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 56de9de..771b72a 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 0b13975..8903a42 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 2e95feb..abf5ba5 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 5d2a4f6..c4d44a3 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 c5b9996..cfc8a2a 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 f5fd2d7..d18e094 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 ab52949..a09316a 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 baf7aa3..b19eb27 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 47854f8..bc6fe06 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 6ee98ee..fb961ae 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 3b01633..1d8c438 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 0000000..c92b604 --- /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 0768af5..d32ce7c 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 9895e06..01c6b39 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 3f80d21..adddf75 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 5fc6ae1..d6b9877 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 cc6dec7..80f904c 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 7968fb2..afe45b1 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;