address review comments and add private blocking

This commit is contained in:
Alejandro Gomez
2023-01-27 22:10:14 +01:00
parent 592a8b04c0
commit 456aa5fb79
23 changed files with 385 additions and 218 deletions

View File

@ -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) ? (
<button className="secondary" type="button" onClick={() => unblock(pubkey)}>
Unblock
</button>
) : (
<button className="secondary" type="button" onClick={() => block(pubkey)}>
Block
</button>
)
}
export default BlockButton

27
src/Element/BlockList.tsx Normal file
View File

@ -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 (
<div className="main-content">
<h3>Muted ({muted.length})</h3>
{muted.map(a => {
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
})}
<h3>Blocked ({blocked.length})</h3>
{blocked.map(a => {
return <ProfilePreview actions={<BlockButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
})}
</div>
)
}

View File

@ -12,11 +12,10 @@ export interface MutedListProps {
} }
export default function MutedList({ pubkey }: MutedListProps) { export default function MutedList({ pubkey }: MutedListProps) {
const { publicKey } = useSelector((s: RootState) => s.login)
const { muted, isMuted, mute, unmute, muteAll } = useModeration(); const { muted, isMuted, mute, unmute, muteAll } = useModeration();
const feed = useMutedFeed(pubkey) const feed = useMutedFeed(pubkey)
const pubkeys = useMemo(() => { const pubkeys = useMemo(() => {
return publicKey === pubkey ? muted : getMuted(feed.store, pubkey); return getMuted(feed.store, pubkey);
}, [feed, pubkey]); }, [feed, pubkey]);
const hasAllMuted = pubkeys.every(isMuted) const hasAllMuted = pubkeys.every(isMuted)

View File

@ -1,5 +1,5 @@
import "./Note.css"; import "./Note.css";
import { useCallback, useMemo, ReactNode } from "react"; import { useCallback, useMemo, useState, ReactNode } from "react";
import { useNavigate, Link } from "react-router-dom"; import { useNavigate, Link } from "react-router-dom";
import { default as NEvent } from "Nostr/Event"; import { default as NEvent } from "Nostr/Event";
@ -27,6 +27,23 @@ export interface NoteProps {
["data-ev"]?: NEvent ["data-ev"]?: NEvent
} }
const HiddenNote = ({ children }: any) => {
const [show, setShow] = useState(false)
return show ? children : (
<div className="card">
<div className="header">
<p>
This note was hidden because of your moderation settings
</p>
<button onClick={() => setShow(true)}>
Show
</button>
</div>
</div>
)
}
export default function Note(props: NoteProps) { export default function Note(props: NoteProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent } = props 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 users = useProfile(pubKeys);
const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]); const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]);
const { isMuted } = useModeration() const { isMuted } = useModeration()
const muted = isMuted(ev.PubKey) const isOpMuted = isMuted(ev.PubKey)
const { ref, inView } = useInView({ triggerOnce: true }); const { ref, inView } = useInView({ triggerOnce: true });
const options = { const options = {
@ -153,9 +170,11 @@ export default function Note(props: NoteProps) {
) )
} }
return muted ? null : ( const note = (
<div className={`note card${highlight ? " active" : ""}${isThread ? " thread" : ""}`} ref={ref}> <div className={`note card${highlight ? " active" : ""}${isThread ? " thread" : ""}`} ref={ref}>
{content()} {content()}
</div> </div>
) )
return isOpMuted ? <HiddenNote>{note}</HiddenNote> : note
} }

View File

@ -1,6 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useSelector } from "react-redux"; 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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Menu, MenuItem } from '@szhsin/react-menu'; import { Menu, MenuItem } from '@szhsin/react-menu';
@ -31,7 +31,7 @@ export default function NoteFooter(props: NoteFooterProps) {
const { related, ev } = props; const { related, ev } = props;
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey); const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const { mute } = useModeration(); const { mute, block } = useModeration();
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences); const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const author = useProfile(ev.RootPubKey)?.get(ev.RootPubKey); const author = useProfile(ev.RootPubKey)?.get(ev.RootPubKey);
const publisher = useEventPublisher(); const publisher = useEventPublisher();
@ -173,7 +173,11 @@ export default function NoteFooter(props: NoteFooterProps) {
</MenuItem> </MenuItem>
<MenuItem onClick={() => mute(ev.PubKey)}> <MenuItem onClick={() => mute(ev.PubKey)}>
<FontAwesomeIcon icon={faCommentSlash} /> <FontAwesomeIcon icon={faCommentSlash} />
Mute author Mute
</MenuItem>
<MenuItem onClick={() => block(ev.PubKey)}>
<FontAwesomeIcon icon={faBan} />
Block
</MenuItem> </MenuItem>
{prefs.showDebugMenus && ( {prefs.showDebugMenus && (
<MenuItem onClick={() => copyEvent()}> <MenuItem onClick={() => copyEvent()}>

View File

@ -20,6 +20,8 @@ export default function NoteReaction(props: NoteReactionProps) {
const { ["data-ev"]: dataEv, data } = props; const { ["data-ev"]: dataEv, data } = props;
const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv]) const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv])
const { isMuted } = useModeration(); const { isMuted } = useModeration();
const pRef = data?.tags.find((a: any) => a[0] === "p")?.at(1);
const isRefMuted = pRef && isMuted(pRef)
const refEvent = useMemo(() => { const refEvent = useMemo(() => {
if (ev) { if (ev) {
@ -57,7 +59,7 @@ export default function NoteReaction(props: NoteReactionProps) {
}; };
const isOpMuted = root && isMuted(root.pubkey) const isOpMuted = root && isMuted(root.pubkey)
return isOpMuted ? null : ( return isOpMuted || isRefMuted ? null : (
<div className="reaction"> <div className="reaction">
<div className="header flex"> <div className="header flex">
<ProfileImage pubkey={ev.RootPubKey} /> <ProfileImage pubkey={ev.RootPubKey} />

View File

@ -69,3 +69,10 @@
width: -webkit-fill-available; width: -webkit-fill-available;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
} }
.text blockquote {
margin: 0;
color: var(--font-secondary-color);
border-left: 2px solid var(--font-secondary-color);
padding-left: 12px;
}

View File

@ -2,7 +2,6 @@ import "./Timeline.css";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faForward } from "@fortawesome/free-solid-svg-icons"; import { faForward } from "@fortawesome/free-solid-svg-icons";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useSelector } from "react-redux";
import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed"; import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed";
import { TaggedRawEvent } from "Nostr"; import { TaggedRawEvent } from "Nostr";
@ -10,7 +9,6 @@ import EventKind from "Nostr/EventKind";
import LoadMore from "Element/LoadMore"; import LoadMore from "Element/LoadMore";
import Note from "Element/Note"; import Note from "Element/Note";
import NoteReaction from "Element/NoteReaction"; import NoteReaction from "Element/NoteReaction";
import type { RootState } from "State/Store";
import useModeration from "Hooks/useModeration"; import useModeration from "Hooks/useModeration";
export interface TimelineProps { export interface TimelineProps {
@ -48,10 +46,7 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin
case EventKind.Reaction: case EventKind.Reaction:
case EventKind.Repost: { case EventKind.Repost: {
let eRef = e.tags.find(a => a[0] === "e")?.at(1); let eRef = e.tags.find(a => a[0] === "e")?.at(1);
let pRef = e.tags.find(a => a[0] === "p")?.at(1); return <NoteReaction data={e} key={e.id} root={parent.notes.find(a => a.id === eRef)}/>
return !muted.includes(pRef || '') ? (
<NoteReaction data={e} key={e.id} root={parent.notes.find(a => a.id === eRef)}/>
) : null
} }
} }
} }

View File

@ -5,8 +5,7 @@ import { default as NEvent } from "Nostr/Event";
import EventKind from "Nostr/EventKind"; import EventKind from "Nostr/EventKind";
import Tag from "Nostr/Tag"; import Tag from "Nostr/Tag";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import { HexKey, RawEvent, u256, UserMetadata } from "Nostr"; import { HexKey, RawEvent, u256, UserMetadata, Lists } from "Nostr";
import { MUTE_LIST_TAG } from "Feed/MuteList";
import { bech32ToHex } from "Util" import { bech32ToHex } from "Util"
import { DefaultRelays, HashtagRegex } from "Const"; 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) { if (pubKey) {
let ev = NEvent.ForPubKey(pubKey); let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Lists; 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 => { keys.forEach(p => {
ev.Tags.push(new Tag(["p", p], ev.Tags.length)) ev.Tags.push(new Tag(["p", p], ev.Tags.length))
}) })
// todo: public/private block let content = ""
ev.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); return await signEvent(ev);
} }
}, },
@ -294,7 +302,7 @@ const delay = (t: number) => {
}); });
} }
const barierNip07 = async (then: () => Promise<any>) => { export const barierNip07 = async (then: () => Promise<any>) => {
while (isNip07Busy) { while (isNip07Busy) {
await delay(10); await delay(10);
} }

View File

@ -1,27 +1,27 @@
import Nostrich from "nostrich.jpg";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux"; 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 EventKind from "Nostr/EventKind";
import Event from "Nostr/Event";
import { Subscriptions } from "Nostr/Subscriptions"; import { Subscriptions } from "Nostr/Subscriptions";
import { addDirectMessage, addNotifications, setFollows, setRelays, setMuted } from "State/Login"; import { addDirectMessage, setFollows, setRelays, setMuted, setBlocked, sendNotification } from "State/Login";
import { RootState } from "State/Store"; import type { RootState } from "State/Store";
import { db } from "Db"; import { db } from "Db";
import { barierNip07 } from "Feed/EventPublisher";
import useSubscription from "Feed/Subscription"; 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 { mapEventToProfile, MetadataCache } from "Db/User";
import { getDisplayName } from "Element/ProfileImage";
import useModeration from "Hooks/useModeration"; import useModeration from "Hooks/useModeration";
import { MentionRegex } from "Const";
/** /**
* Managed loading data for the current logged in user * Managed loading data for the current logged in user
*/ */
export default function useLoginFeed() { export default function useLoginFeed() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { isMuted } = useModeration(); const { publicKey: pubKey, privateKey: privKey } = useSelector((s: RootState) => s.login);
const [pubKey, readNotifications] = useSelector<RootState, [HexKey | undefined, number]>(s => [s.login.publicKey, s.login.readNotifications]); const { isMuted } = useModeration();
const subMetadata = useMemo(() => { const subMetadata = useMemo(() => {
if (!pubKey) return null; if (!pubKey) return null;
@ -53,7 +53,7 @@ export default function useLoginFeed() {
sub.Id = "login:muted"; sub.Id = "login:muted";
sub.Kinds = new Set([EventKind.Lists]); sub.Kinds = new Set([EventKind.Lists]);
sub.Authors = new Set([pubKey]); sub.Authors = new Set([pubKey]);
sub.DTags = new Set([MUTE_LIST_TAG]) sub.DTag = Lists.Muted;
sub.Limit = 1; sub.Limit = 1;
return sub; return sub;
@ -103,7 +103,7 @@ export default function useLoginFeed() {
acc.created = v.created; acc.created = v.created;
} }
return acc; return acc;
}, { created: 0, profile: <MetadataCache | null>null }); }, { created: 0, profile: null as MetadataCache | null });
if (maxProfile.profile) { if (maxProfile.profile) {
let existing = await db.users.get(maxProfile.profile.pubkey); let existing = await db.users.get(maxProfile.profile.pubkey);
if ((existing?.created ?? 0) < maxProfile.created) { if ((existing?.created ?? 0) < maxProfile.created) {
@ -111,75 +111,52 @@ export default function useLoginFeed() {
} }
} }
})().catch(console.warn); })().catch(console.warn);
}, [metadataFeed.store]); }, [dispatch, metadataFeed.store]);
useEffect(() => { useEffect(() => {
let notifications = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote && !isMuted(a.pubkey)) const replies = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote && !isMuted(a.pubkey))
replies.forEach(nx => {
if ("Notification" in window && Notification.permission === "granted") { makeNotification(nx).then(notification => {
for (let nx of notifications.filter(a => (a.created_at * 1000) > readNotifications)) { if (notification) {
sendNotification(nx) // @ts-ignore
.catch(console.warn); dispatch(sendNotification(notification))
} }
} })
})
dispatch(addNotifications(notifications)); }, [dispatch, notificationFeed.store]);
}, [notificationFeed.store]);
useEffect(() => { useEffect(() => {
const ps = getMutedKeys(mutedFeed.store.notes) const muted = getMutedKeys(mutedFeed.store.notes)
dispatch(setMuted(ps)) dispatch(setMuted(muted))
}, [mutedFeed.store])
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(() => { useEffect(() => {
let dms = dmsFeed.store.notes.filter(a => a.kind === EventKind.DirectMessage); let dms = dmsFeed.store.notes.filter(a => a.kind === EventKind.DirectMessage);
dispatch(addDirectMessage(dms)); dispatch(addDirectMessage(dms));
}, [dmsFeed.store]); }, [dispatch, dmsFeed.store]);
} }
async function makeNotification(ev: TaggedRawEvent) { async function decryptBlocked(raw: TaggedRawEvent, pubKey: HexKey, privKey?: HexKey) {
switch (ev.kind) { const ev = new Event(raw)
case EventKind.TextNote: { if (pubKey && privKey) {
const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1]!)]); return await ev.DecryptData(raw.content, privKey, pubKey)
const users = (await db.users.bulkGet(Array.from(pubkeys))).filter(a => a !== undefined).map(a => a!); } else {
const fromUser = users.find(a => a?.pubkey === ev.pubkey); return await barierNip07(() => window.nostr.nip04.decrypt(pubKey, raw.content));
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]
});
}
} }

View File

@ -1,45 +1,41 @@
import { useMemo } from "react"; 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 EventKind from "Nostr/EventKind";
import { Subscriptions } from "Nostr/Subscriptions"; import { Subscriptions } from "Nostr/Subscriptions";
import type { RootState } from "State/Store";
import useSubscription, { NoteStore } from "Feed/Subscription"; import useSubscription, { NoteStore } from "Feed/Subscription";
export const MUTE_LIST_TAG = "p:mute"
export default function useMutedFeed(pubkey: HexKey) { export default function useMutedFeed(pubkey: HexKey) {
const loginPubkey = useSelector((s: RootState) => s.login.publicKey)
const sub = useMemo(() => { const sub = useMemo(() => {
if (pubkey === loginPubkey) return null
let sub = new Subscriptions(); let sub = new Subscriptions();
sub.Id = `muted:${pubkey}`; sub.Id = `muted:${pubkey}`;
sub.Kinds = new Set([EventKind.Lists]); sub.Kinds = new Set([EventKind.Lists]);
sub.Authors = new Set([pubkey]); sub.Authors = new Set([pubkey]);
sub.DTags = new Set([MUTE_LIST_TAG]) sub.DTag = Lists.Muted;
sub.Limit = 1; sub.Limit = 1;
return sub; return sub;
}, [pubkey]); }, [pubkey]);
return useSubscription(sub); return useSubscription(sub);
} }
export function getMutedKeys(rawNotes: TaggedRawEvent[]): { createdAt: number, keys: HexKey[] } { export function getNewest(rawNotes: TaggedRawEvent[]){
const notes = [...rawNotes] const notes = [...rawNotes]
notes.sort((a, b) => a.created_at - b.created_at) 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) { if (newest) {
const { tags } = newest const { created_at, tags } = newest
const mutedIndex = tags.findIndex(t => t[0] === "d" && t[1] === MUTE_LIST_TAG) const keys = tags.filter(t => t[0] === "p").map(t => t[1])
if (mutedIndex !== -1) { return {
return { keys,
createdAt: newest.created_at, createdAt: created_at,
keys: tags.slice(mutedIndex).filter(t => t[0] === "p").map(t => t[1]) }
}
}
} }
return { createdAt: 0, keys: [] } return { createdAt: 0, keys: [] }
} }

View File

@ -3,26 +3,30 @@ import { useSelector, useDispatch } from "react-redux";
import type { RootState } from "State/Store"; import type { RootState } from "State/Store";
import { HexKey } from "Nostr"; import { HexKey } from "Nostr";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
import { setMuted } from "State/Login"; import { setMuted, setBlocked } from "State/Login";
export default function useModeration() { export default function useModeration() {
const dispatch = useDispatch() const dispatch = useDispatch()
const { muted } = useSelector((s: RootState) => s.login) const { blocked, muted } = useSelector((s: RootState) => s.login)
const publisher = useEventPublisher() const publisher = useEventPublisher()
async function setMutedList(ids: HexKey[]) { async function setMutedList(pub: HexKey[], priv: HexKey[]) {
try { try {
const ev = await publisher.muted(ids) const ev = await publisher.muted(pub, priv)
console.debug(ev); console.debug(ev);
publisher.broadcast(ev) publisher.broadcast(ev)
} catch (error) { } catch (error) {
console.debug("Couldn't change mute list") console.debug("Couldn't change mute list")
}
} }
}
function isMuted(id: HexKey) { 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) { function unmute(id: HexKey) {
@ -31,26 +35,44 @@ export default function useModeration() {
createdAt: new Date().getTime(), createdAt: new Date().getTime(),
keys: newMuted 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) { function mute(id: HexKey) {
const newMuted = muted.concat([id]) const newMuted = muted.includes(id) ? muted : muted.concat([id])
setMutedList(newMuted) setMutedList(newMuted, blocked)
dispatch(setMuted({ dispatch(setMuted({
createdAt: new Date().getTime(), createdAt: new Date().getTime(),
keys: newMuted 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[]) { function muteAll(ids: HexKey[]) {
const newMuted = Array.from(new Set(muted.concat(ids))) const newMuted = Array.from(new Set(muted.concat(ids)))
setMutedList(newMuted) setMutedList(newMuted, blocked)
dispatch(setMuted({ dispatch(setMuted({
createdAt: new Date().getTime(), createdAt: new Date().getTime(),
keys: newMuted keys: newMuted
})) }))
} }
return { muted, mute, muteAll, unmute, isMuted } return { muted, mute, muteAll, unmute, isMuted, blocked, block, unblock, isBlocked }
} }

View File

@ -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 key = await this._GetDmSharedKey(pubkey, privkey);
let iv = window.crypto.getRandomValues(new Uint8Array(16)); 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({ let result = await window.crypto.subtle.encrypt({
name: "AES-CBC", name: "AES-CBC",
iv: iv iv: iv
}, key, data); }, key, data);
let uData = new Uint8Array(result); 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 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])); let data = new Uint8Array(base64.length(cSplit[0]));
base64.decode(cSplit[0], data, 0); base64.decode(cSplit[0], data, 0);
@ -169,7 +176,14 @@ export default class Event {
name: "AES-CBC", name: "AES-CBC",
iv: iv iv: iv
}, key, data); }, 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) { async _GetDmSharedKey(pubkey: HexKey, privkey: HexKey) {

View File

@ -43,9 +43,9 @@ export class Subscriptions {
HashTags?: Set<string>; HashTags?: Set<string>;
/** /**
* A list of "d" tags to search * A "d" tag to search
*/ */
DTags?: Set<string>; DTag?: string;
/** /**
* a timestamp, events must be newer than this to pass * 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.Kinds = sub?.kinds ? new Set(sub.kinds) : undefined;
this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : undefined; this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : undefined;
this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : 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.Since = sub?.since ?? undefined;
this.Until = sub?.until ?? undefined; this.Until = sub?.until ?? undefined;
this.Limit = sub?.limit ?? undefined; this.Limit = sub?.limit ?? undefined;
@ -139,8 +139,8 @@ export class Subscriptions {
if (this.HashTags) { if (this.HashTags) {
ret["#t"] = Array.from(this.HashTags); ret["#t"] = Array.from(this.HashTags);
} }
if (this.DTags) { if (this.DTag) {
ret["#d"] = Array.from(this.DTags); ret["#d"] = this.DTag;
} }
if (this.Since !== null) { if (this.Since !== null) {
ret.since = this.Since; ret.since = this.Since;

View File

@ -67,7 +67,7 @@ export default class Tag {
return ["t", this.Hashtag!]; return ["t", this.Hashtag!];
} }
case "d": { case "d": {
return ["t", this.DTag!]; return ["d", this.DTag!];
} }
default: { default: {
return this.Original; return this.Original;

View File

@ -35,7 +35,7 @@ export type RawReqFilter = {
"#e"?: u256[], "#e"?: u256[],
"#p"?: u256[], "#p"?: u256[],
"#t"?: string[], "#t"?: string[],
"#d"?: string[], "#d"?: string,
since?: number, since?: number,
until?: number, until?: number,
limit?: number limit?: number
@ -55,3 +55,10 @@ export type UserMetadata = {
lud06?: string, lud06?: string,
lud16?: string lud16?: string
} }
/**
* NIP-51 list types
*/
export enum Lists {
Muted = "mute"
}

43
src/Notifications.ts Normal file
View File

@ -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<NotificationRequest | null> {
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();
}

View File

@ -20,7 +20,7 @@ export default function LoginPage() {
if (publicKey) { if (publicKey) {
navigate("/"); navigate("/");
} }
}, [publicKey]); }, [publicKey, navigate]);
async function getNip05PubKey(addr: string) { async function getNip05PubKey(addr: string) {
let [username, domain] = addr.split("@"); let [username, domain] = addr.split("@");
@ -32,7 +32,7 @@ export default function LoginPage() {
return pKey; return pKey;
} }
} }
throw "User key not found" throw new Error("User key not found")
} }
async function doLogin() { async function doLogin() {
@ -43,7 +43,7 @@ export default function LoginPage() {
if (secp.utils.isValidPrivateKey(hexKey)) { if (secp.utils.isValidPrivateKey(hexKey)) {
dispatch(setPrivateKey(hexKey)); dispatch(setPrivateKey(hexKey));
} else { } else {
throw "INVALID PRIVATE KEY"; throw new Error("INVALID PRIVATE KEY");
} }
} else if (key.startsWith("npub")) { } else if (key.startsWith("npub")) {
let hexKey = bech32ToHex(key); let hexKey = bech32ToHex(key);
@ -55,7 +55,7 @@ export default function LoginPage() {
if (secp.utils.isValidPrivateKey(key)) { if (secp.utils.isValidPrivateKey(key)) {
dispatch(setPrivateKey(key)); dispatch(setPrivateKey(key));
} else { } else {
throw "INVALID PRIVATE KEY"; throw new Error("INVALID PRIVATE KEY");
} }
} }
} catch (e) { } catch (e) {

View File

@ -21,11 +21,11 @@ export default function MessagesPage() {
const myPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey); const myPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const dms = useSelector<RootState, RawEvent[]>(s => s.login.dms); const dms = useSelector<RootState, RawEvent[]>(s => s.login.dms);
const dmInteraction = useSelector<RootState, number>(s => s.login.dmInteraction); const dmInteraction = useSelector<RootState, number>(s => s.login.dmInteraction);
const { muted, isMuted } = useModeration(); const { isMuted } = useModeration();
const chats = useMemo(() => { const chats = useMemo(() => {
return extractChats(dms.filter(a => !isMuted(a.pubkey)), myPubKey!); return extractChats(dms.filter(a => !isMuted(a.pubkey)), myPubKey!)
}, [dms, myPubKey, dmInteraction, muted]); }, [dms, myPubKey, dmInteraction]);
function noteToSelf(chat: DmChat) { function noteToSelf(chat: DmChat) {
return ( return (
@ -93,7 +93,7 @@ export function isToSelf(e: RawEvent, pk: HexKey) {
} }
export function dmsInChat(dms: 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) { export function totalUnread(dms: RawEvent[], myPubKey: HexKey) {

View File

@ -18,6 +18,7 @@ import Nip05 from "Element/Nip05";
import Copy from "Element/Copy"; import Copy from "Element/Copy";
import ProfilePreview from "Element/ProfilePreview"; import ProfilePreview from "Element/ProfilePreview";
import FollowersList from "Element/FollowersList"; import FollowersList from "Element/FollowersList";
import BlockList from "Element/BlockList";
import MutedList from "Element/MutedList"; import MutedList from "Element/MutedList";
import FollowsList from "Element/FollowsList"; import FollowsList from "Element/FollowsList";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
@ -123,7 +124,7 @@ export default function ProfilePage() {
return <FollowersList pubkey={id} /> return <FollowersList pubkey={id} />
} }
case ProfileTab.Muted: { case ProfileTab.Muted: {
return <MutedList pubkey={id} /> return isMe ? <BlockList /> : <MutedList pubkey={id} />
} }
} }
} }

View File

@ -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 "./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 SettingsIndex = () => {
const dispatch = useDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
function handleLogout() {
dispatch(logout())
navigate("/")
}
return ( return (
<>
<div className="settings-nav"> <div className="settings-nav">
<div className="card" onClick={() => navigate("profile")}> <div className="card" onClick={() => navigate("profile")}>
<FontAwesomeIcon icon={faUser} size="xl" className="mr10" /> <FontAwesomeIcon icon={faUser} size="xl" className="mr10" />
@ -24,7 +35,12 @@ const SettingsIndex = () => {
<FontAwesomeIcon icon={faCircleDollarToSlot} size="xl" className="mr10" /> <FontAwesomeIcon icon={faCircleDollarToSlot} size="xl" className="mr10" />
Donate Donate
</div> </div>
<div className="card" onClick={handleLogout}>
<FontAwesomeIcon icon={faRightFromBracket} size="xl" className="mr10" />
Log Out
</div>
</div> </div>
</>
) )
} }

View File

@ -3,12 +3,20 @@ import * as secp from '@noble/secp256k1';
import { DefaultRelays } from 'Const'; import { DefaultRelays } from 'Const';
import { HexKey, TaggedRawEvent } from 'Nostr'; import { HexKey, TaggedRawEvent } from 'Nostr';
import { RelaySettings } from 'Nostr/Connection'; import { RelaySettings } from 'Nostr/Connection';
import type { AppDispatch, RootState } from "State/Store";
const PrivateKeyItem = "secret"; const PrivateKeyItem = "secret";
const PublicKeyItem = "pubkey"; const PublicKeyItem = "pubkey";
const NotificationsReadItem = "notifications-read"; const NotificationsReadItem = "notifications-read";
const UserPreferencesKey = "preferences"; const UserPreferencesKey = "preferences";
export interface NotificationRequest {
title: string
body: string
icon: string
timestamp: number
}
export interface UserPreferences { export interface UserPreferences {
/** /**
* Enable reactions / reposts / zaps * Enable reactions / reposts / zaps
@ -87,6 +95,11 @@ export interface LoginStore {
*/ */
latestMuted: number, latestMuted: number,
/**
* A list of pubkeys this user has muted privately
*/
blocked: HexKey[],
/** /**
* Notifications for this login session * Notifications for this login session
*/ */
@ -122,6 +135,7 @@ const InitState = {
follows: [], follows: [],
latestFollows: 0, latestFollows: 0,
muted: [], muted: [],
blocked: [],
latestMuted: 0, latestMuted: 0,
notifications: [], notifications: [],
readNotifications: new Date().getTime(), readNotifications: new Date().getTime(),
@ -238,49 +252,26 @@ const LoginSlice = createSlice({
}, },
setMuted(state, action: PayloadAction<{createdAt: number, keys: HexKey[]}>) { setMuted(state, action: PayloadAction<{createdAt: number, keys: HexKey[]}>) {
const { createdAt, keys } = action.payload const { createdAt, keys } = action.payload
if (createdAt > state.latestMuted) { if (createdAt >= state.latestMuted) {
const muted = new Set([...keys]) const muted = new Set([...keys])
state.muted = Array.from(muted) state.muted = Array.from(muted)
state.latestMuted = createdAt state.latestMuted = createdAt
} }
}, },
addNotifications: (state, action: PayloadAction<TaggedRawEvent | TaggedRawEvent[]>) => { setBlocked(state, action: PayloadAction<{createdAt: number, keys: HexKey[]}>) {
let n = action.payload; const { createdAt, keys } = action.payload
if (!Array.isArray(n)) { if (createdAt >= state.latestMuted) {
n = [n]; const blocked = new Set([...keys])
} state.blocked = Array.from(blocked)
state.latestMuted = createdAt
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
];
}
}, },
addDirectMessage: (state, action: PayloadAction<TaggedRawEvent | Array<TaggedRawEvent>>) => { addDirectMessage: (state, action: PayloadAction<TaggedRawEvent | Array<TaggedRawEvent>>) => {
let n = action.payload; let n = action.payload;
if (!Array.isArray(n)) { if (!Array.isArray(n)) {
n = [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) => { incDmInteraction: (state) => {
state.dmInteraction += 1; state.dmInteraction += 1;
@ -309,12 +300,36 @@ export const {
setRelays, setRelays,
removeRelay, removeRelay,
setFollows, setFollows,
addNotifications,
setMuted, setMuted,
setBlocked,
addDirectMessage, addDirectMessage,
incDmInteraction, incDmInteraction,
logout, logout,
markNotificationsRead, markNotificationsRead,
setPreferences, setPreferences,
} = LoginSlice.actions; } = 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; export const reducer = LoginSlice.reducer;

View File

@ -397,27 +397,6 @@ body.scroll-lock {
margin-right: auto; 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 { .error {
color: var(--error); color: var(--error);
} }
@ -431,12 +410,27 @@ body.scroll-lock {
} }
.tabs { .tabs {
padding: 0; display: flex;
align-items: center; align-content: center;
justify-content: flex-start; text-align: center;
margin-top: 10px;
overflow-x: auto;
margin-bottom: 16px; margin-bottom: 16px;
} }
.tabs > * {
margin-right: 10px;
cursor: pointer;
}
.tabs > *:last-child {
margin: 0;
}
.tabs .active {
font-weight: 700;
}
.tab { .tab {
border-bottom: 1px solid var(--gray-secondary); border-bottom: 1px solid var(--gray-secondary);
font-weight: 700; font-weight: 700;