feat: NIP-51

This commit is contained in:
Alejandro Gomez 2023-01-26 12:34:18 +01:00
parent 83df146716
commit cfbf244955
No known key found for this signature in database
GPG Key ID: 4DF39E566658C817
21 changed files with 314 additions and 35 deletions

View File

@ -25,4 +25,5 @@ Snort supports the following NIP's
- [ ] NIP-28: Public Chat - [ ] NIP-28: Public Chat
- [ ] NIP-36: Sensitive Content - [ ] NIP-36: Sensitive Content
- [ ] NIP-40: Expiration Timestamp - [ ] NIP-40: Expiration Timestamp
- [ ] NIP-42: Authentication of clients to relays - [ ] NIP-42: Authentication of clients to relays
- [ ] NIP-51: Lists

View File

@ -79,9 +79,9 @@ export default function Invoice(props: InvoiceProps) {
</div> </div>
{info?.expired ? <div className="btn">Expired</div> : ( {info?.expired ? <div className="btn">Expired</div> : (
<div className="btn" onClick={payInvoice}> <button type="button" onClick={payInvoice}>
Pay Pay
</div> </button>
)} )}
</div> </div>

View File

@ -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 (
<button className="secondary" type="button" onClick={() => { dispatch(logout()); navigate("/"); }}>
Logout
</button>
)
}

View File

@ -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) ? (
<button className="secondary" type="button" onClick={() => unmute(pubkey)}>
Unmute
</button>
) : (
<button className="secondary" type="button" onClick={() => mute(pubkey)}>
Mute
</button>
)
}
export default MuteButton

39
src/Element/MutedList.tsx Normal file
View File

@ -0,0 +1,39 @@
import { useMemo } from "react";
import { useSelector } from "react-redux";
import { HexKey } from "Nostr"; import type { RootState } from "State/Store";
import MuteButton from "Element/MuteButton";
import ProfilePreview from "Element/ProfilePreview";
import useMutedFeed, { getMuted } from "Feed/MuteList";
import useModeration from "Hooks/useModeration";
export interface MutedListProps {
pubkey: HexKey
}
export default function MutedList({ pubkey }: MutedListProps) {
const { publicKey } = useSelector((s: RootState) => s.login)
const { muted, isMuted, mute, unmute, muteAll } = useModeration();
const feed = useMutedFeed(pubkey)
const pubkeys = useMemo(() => {
return publicKey === pubkey ? muted : getMuted(feed.store, pubkey);
}, [feed, pubkey]);
const hasAllMuted = pubkeys.every(isMuted)
return (
<div className="main-content">
<div className="flex mt10">
<div className="f-grow bold">{`${pubkeys?.length} muted`}</div>
<button
disabled={hasAllMuted || pubkeys.length === 0}
className="transparent" type="button" onClick={() => muteAll(pubkeys)}
>
Mute all
</button>
</div>
{pubkeys?.map(a => {
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
})}
</div>
)
}

View File

@ -12,6 +12,7 @@ import EventKind from "Nostr/EventKind";
import useProfile from "Feed/ProfileFeed"; import useProfile from "Feed/ProfileFeed";
import { TaggedRawEvent, u256 } from "Nostr"; import { TaggedRawEvent, u256 } from "Nostr";
import { useInView } from "react-intersection-observer"; import { useInView } from "react-intersection-observer";
import useModeration from "Hooks/useModeration";
export interface NoteProps { export interface NoteProps {
data?: TaggedRawEvent, data?: TaggedRawEvent,
@ -33,6 +34,8 @@ export default function Note(props: NoteProps) {
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]); const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
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 muted = isMuted(ev.PubKey)
const { ref, inView } = useInView({ triggerOnce: true }); const { ref, inView } = useInView({ triggerOnce: true });
const options = { const options = {
@ -150,7 +153,7 @@ export default function Note(props: NoteProps) {
) )
} }
return ( return muted ? null : (
<div className={`note card${highlight ? " active" : ""}${isThread ? " thread" : ""}`} ref={ref}> <div className={`note card${highlight ? " active" : ""}${isThread ? " thread" : ""}`} ref={ref}>
{content()} {content()}
</div> </div>

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 } from "@fortawesome/free-solid-svg-icons"; import { faTrash, faRepeat, faShareNodes, faCopy, faCommentSlash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Menu, MenuItem } from '@szhsin/react-menu'; import { Menu, MenuItem } from '@szhsin/react-menu';
@ -20,6 +20,7 @@ import { RootState } from "State/Store";
import { HexKey, TaggedRawEvent } from "Nostr"; import { HexKey, TaggedRawEvent } from "Nostr";
import EventKind from "Nostr/EventKind"; import EventKind from "Nostr/EventKind";
import { UserPreferences } from "State/Login"; import { UserPreferences } from "State/Login";
import useModeration from "Hooks/useModeration";
export interface NoteFooterProps { export interface NoteFooterProps {
related: TaggedRawEvent[], related: TaggedRawEvent[],
@ -30,6 +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 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();
@ -169,6 +171,10 @@ export default function NoteFooter(props: NoteFooterProps) {
<FontAwesomeIcon icon={faCopy} /> <FontAwesomeIcon icon={faCopy} />
Copy ID Copy ID
</MenuItem> </MenuItem>
<MenuItem onClick={() => mute(ev.PubKey)}>
<FontAwesomeIcon icon={faCommentSlash} />
Mute author
</MenuItem>
{prefs.showDebugMenus && ( {prefs.showDebugMenus && (
<MenuItem onClick={() => copyEvent()}> <MenuItem onClick={() => copyEvent()}>
<FontAwesomeIcon icon={faCopy} /> <FontAwesomeIcon icon={faCopy} />

View File

@ -9,6 +9,7 @@ import { default as NEvent } from "Nostr/Event";
import { eventLink, hexToBech32 } from "Util"; import { eventLink, hexToBech32 } from "Util";
import NoteTime from "Element/NoteTime"; import NoteTime from "Element/NoteTime";
import { RawEvent, TaggedRawEvent } from "Nostr"; import { RawEvent, TaggedRawEvent } from "Nostr";
import useModeration from "Hooks/useModeration";
export interface NoteReactionProps { export interface NoteReactionProps {
data?: TaggedRawEvent, data?: TaggedRawEvent,
@ -18,6 +19,7 @@ export interface NoteReactionProps {
export default function NoteReaction(props: NoteReactionProps) { 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 refEvent = useMemo(() => { const refEvent = useMemo(() => {
if (ev) { if (ev) {
@ -53,8 +55,9 @@ export default function NoteReaction(props: NoteReactionProps) {
showHeader: ev?.Kind === EventKind.Repost, showHeader: ev?.Kind === EventKind.Repost,
showFooter: false, showFooter: false,
}; };
const isOpMuted = root && isMuted(root.pubkey)
return ( return isOpMuted ? 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

@ -1,13 +1,16 @@
import "./Timeline.css"; import "./Timeline.css";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
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";
import EventKind from "Nostr/EventKind"; 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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import type { RootState } from "State/Store";
import { faForward } from "@fortawesome/free-solid-svg-icons";
export interface TimelineProps { export interface TimelineProps {
postsOnly: boolean, postsOnly: boolean,
@ -19,21 +22,22 @@ export interface TimelineProps {
* A list of notes by pubkeys * A list of notes by pubkeys
*/ */
export default function Timeline({ subject, postsOnly = false, method }: TimelineProps) { export default function Timeline({ subject, postsOnly = false, method }: TimelineProps) {
const muted = useSelector((s: RootState) => s.login.muted)
const { main, related, latest, parent, loadMore, showLatest } = useTimelineFeed(subject, { const { main, related, latest, parent, loadMore, showLatest } = useTimelineFeed(subject, {
method method
}); });
const filterPosts = useCallback((nts: TaggedRawEvent[]) => { 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); return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true).filter(a => !muted.includes(a.pubkey));
}, [postsOnly]); }, [postsOnly, muted]);
const mainFeed = useMemo(() => { const mainFeed = useMemo(() => {
return filterPosts(main.notes); return filterPosts(main.notes);
}, [main, filterPosts]); }, [main, filterPosts]);
const latestFeed = useMemo(() => { 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]); }, [latest, mainFeed, filterPosts, muted]);
function eventElement(e: TaggedRawEvent) { function eventElement(e: TaggedRawEvent) {
switch (e.kind) { switch (e.kind) {
@ -43,7 +47,10 @@ 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);
return <NoteReaction data={e} key={e.id} root={parent.notes.find(a => a.id === eRef)}/> let pRef = e.tags.find(a => a[0] === "p")?.at(1);
return !muted.includes(pRef || '') ? (
<NoteReaction data={e} key={e.id} root={parent.notes.find(a => a.id === eRef)}/>
) : null
} }
} }
} }

View File

@ -95,6 +95,19 @@ export default function useEventPublisher() {
} }
} }
}, },
muted: async (keys: HexKey[]) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Lists;
ev.Tags.push(new Tag(["d", "mute"], ev.Tags.length))
keys.forEach(p => {
ev.Tags.push(new Tag(["p", p], ev.Tags.length))
})
// todo: public/private block
ev.Content = "";
return await signEvent(ev);
}
},
metadata: async (obj: UserMetadata) => { metadata: async (obj: UserMetadata) => {
if (pubKey) { if (pubKey) {
let ev = NEvent.ForPubKey(pubKey); let ev = NEvent.ForPubKey(pubKey);

View File

@ -5,10 +5,11 @@ import { useDispatch, useSelector } from "react-redux";
import { HexKey, TaggedRawEvent } from "Nostr"; import { HexKey, TaggedRawEvent } from "Nostr";
import EventKind from "Nostr/EventKind"; import EventKind from "Nostr/EventKind";
import { Subscriptions } from "Nostr/Subscriptions"; import { Subscriptions } from "Nostr/Subscriptions";
import { addDirectMessage, addNotifications, setFollows, setRelays } from "State/Login"; import { addDirectMessage, addNotifications, setFollows, setRelays, setMuted } from "State/Login";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import { db } from "Db"; import { db } from "Db";
import useSubscription from "Feed/Subscription"; import useSubscription from "Feed/Subscription";
import { MUTE_LIST_TAG, getMutedKeys } from "Feed/MuteList";
import { mapEventToProfile, MetadataCache } from "Db/User"; import { mapEventToProfile, MetadataCache } from "Db/User";
import { getDisplayName } from "Element/ProfileImage"; import { getDisplayName } from "Element/ProfileImage";
import { MentionRegex } from "Const"; import { MentionRegex } from "Const";
@ -18,7 +19,7 @@ import { MentionRegex } from "Const";
*/ */
export default function useLoginFeed() { export default function useLoginFeed() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [pubKey, readNotifications] = useSelector<RootState, [HexKey | undefined, number]>(s => [s.login.publicKey, s.login.readNotifications]); const [pubKey, readNotifications, muted] = useSelector<RootState, [HexKey | undefined, number, HexKey[]]>(s => [s.login.publicKey, s.login.readNotifications, s.login.muted]);
const subMetadata = useMemo(() => { const subMetadata = useMemo(() => {
if (!pubKey) return null; if (!pubKey) return null;
@ -42,6 +43,20 @@ export default function useLoginFeed() {
return sub; return sub;
}, [pubKey]); }, [pubKey]);
const subMuted = useMemo(() => {
if (!pubKey) return null;
let sub = new Subscriptions();
sub.Id = "login:muted";
sub.Kinds = new Set([EventKind.Lists]);
sub.Authors = new Set([pubKey]);
// TODO: not sure relay support this atm, don't seem to return results
// sub.DTags = new Set([MUTE_LIST_TAG])
sub.Limit = 1;
return sub;
}, [pubKey]);
const subDms = useMemo(() => { const subDms = useMemo(() => {
if (!pubKey) return null; if (!pubKey) return null;
@ -61,6 +76,7 @@ export default function useLoginFeed() {
const metadataFeed = useSubscription(subMetadata, { leaveOpen: true }); const metadataFeed = useSubscription(subMetadata, { leaveOpen: true });
const notificationFeed = useSubscription(subNotification, { leaveOpen: true }); const notificationFeed = useSubscription(subNotification, { leaveOpen: true });
const dmsFeed = useSubscription(subDms, { leaveOpen: true }); const dmsFeed = useSubscription(subDms, { leaveOpen: true });
const mutedFeed = useSubscription(subMuted, { leaveOpen: true });
useEffect(() => { useEffect(() => {
let contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList); let contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList);
@ -96,7 +112,7 @@ export default function useLoginFeed() {
}, [metadataFeed.store]); }, [metadataFeed.store]);
useEffect(() => { useEffect(() => {
let notifications = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote); let notifications = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote && !muted.includes(a.pubkey))
if ("Notification" in window && Notification.permission === "granted") { if ("Notification" in window && Notification.permission === "granted") {
for (let nx of notifications.filter(a => (a.created_at * 1000) > readNotifications)) { for (let nx of notifications.filter(a => (a.created_at * 1000) > readNotifications)) {
@ -108,6 +124,11 @@ export default function useLoginFeed() {
dispatch(addNotifications(notifications)); dispatch(addNotifications(notifications));
}, [notificationFeed.store]); }, [notificationFeed.store]);
useEffect(() => {
const ps = getMutedKeys(mutedFeed.store.notes)
dispatch(setMuted(ps))
}, [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));
@ -159,4 +180,4 @@ async function sendNotification(ev: TaggedRawEvent) {
vibrate: [500] vibrate: [500]
}); });
} }
} }

51
src/Feed/MuteList.ts Normal file
View File

@ -0,0 +1,51 @@
import { useMemo } from "react";
import { useSelector } from "react-redux";
import { HexKey, TaggedRawEvent } from "Nostr";
import EventKind from "Nostr/EventKind";
import { Subscriptions } from "Nostr/Subscriptions";
import type { RootState } from "State/Store";
import useSubscription, { NoteStore } from "Feed/Subscription";
export const MUTE_LIST_TAG = "mute"
export default function useMutedFeed(pubkey: HexKey) {
const loginPubkey = useSelector((s: RootState) => s.login.publicKey)
const sub = useMemo(() => {
if (pubkey === loginPubkey) return null
let sub = new Subscriptions();
sub.Id = `muted:${pubkey}`;
sub.Kinds = new Set([EventKind.Lists]);
sub.Authors = new Set([pubkey]);
// TODO: not sure relay support this atm, don't seem to return results
//sub.DTags = new Set([MUTE_LIST_TAG])
sub.Limit = 1;
return sub;
}, [pubkey]);
return useSubscription(sub);
}
export function getMutedKeys(rawNotes: TaggedRawEvent[]): { at: number, keys: HexKey[] } {
const notes = [...rawNotes]
notes.sort((a, b) => a.created_at - b.created_at)
const newest = notes && notes[0]
if (newest) {
const { tags } = newest
const mutedIndex = tags.findIndex(t => t[0] === "d" && t[1] === MUTE_LIST_TAG)
if (mutedIndex !== -1) {
return {
at: newest.created_at,
keys: tags.slice(mutedIndex).filter(t => t[0] === "p").map(t => t[1])
}
}
}
return { at: 0, keys: [] }
}
export function getMuted(feed: NoteStore, pubkey: HexKey): HexKey[] {
let lists = feed?.notes.filter(a => a.kind === EventKind.Lists && a.pubkey === pubkey);
return getMutedKeys(lists).keys;
}

View File

@ -0,0 +1,56 @@
import { useSelector, useDispatch } from "react-redux";
import type { RootState } from "State/Store";
import { HexKey } from "Nostr";
import useEventPublisher from "Feed/EventPublisher";
import { setMuted } from "State/Login";
export default function useModeration() {
const dispatch = useDispatch()
const { muted } = useSelector((s: RootState) => s.login)
const publisher = useEventPublisher()
async function setMutedList(ids: HexKey[]) {
try {
const ev = await publisher.muted(ids)
console.debug(ev);
publisher.broadcast(ev)
} catch (error) {
console.debug("Couldn't change mute list")
}
}
function isMuted(id: HexKey) {
return muted.includes(id)
}
function unmute(id: HexKey) {
const newMuted = muted.filter(p => p !== id)
dispatch(setMuted({
at: new Date().getTime(),
keys: newMuted
}))
setMutedList(newMuted)
}
function mute(id: HexKey) {
const newMuted = muted.concat([id])
setMutedList(newMuted)
dispatch(setMuted({
at: new Date().getTime(),
keys: newMuted
}))
}
function muteAll(ids: HexKey[]) {
const newMuted = Array.from(new Set(muted.concat(ids)))
setMutedList(newMuted)
dispatch(setMuted({
at: new Date().getTime(),
keys: newMuted
}))
}
return { muted, mute, muteAll, unmute, isMuted }
}

View File

@ -7,7 +7,8 @@ const enum EventKind {
DirectMessage = 4, // NIP-04 DirectMessage = 4, // NIP-04
Deletion = 5, // NIP-09 Deletion = 5, // NIP-09
Repost = 6, // NIP-18 Repost = 6, // NIP-18
Reaction = 7 // NIP-25 Reaction = 7, // NIP-25
Lists = 30000, // NIP-51
}; };
export default EventKind; export default EventKind;

View File

@ -42,6 +42,11 @@ export class Subscriptions {
*/ */
HashTags?: Set<string>; HashTags?: Set<string>;
/**
* A list of "d" tags to search
*/
DTags?: Set<string>;
/** /**
* a timestamp, events must be newer than this to pass * 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.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.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;
@ -130,9 +136,12 @@ export class Subscriptions {
if (this.PTags) { if (this.PTags) {
ret["#p"] = Array.from(this.PTags); ret["#p"] = Array.from(this.PTags);
} }
if(this.HashTags) { if (this.HashTags) {
ret["#t"] = Array.from(this.HashTags); ret["#t"] = Array.from(this.HashTags);
} }
if (this.DTags) {
ret["#d"] = Array.from(this.DTags);
}
if (this.Since !== null) { if (this.Since !== null) {
ret.since = this.Since; ret.since = this.Since;
} }
@ -144,4 +153,4 @@ export class Subscriptions {
} }
return ret; return ret;
} }
} }

View File

@ -35,6 +35,7 @@ export type RawReqFilter = {
"#e"?: u256[], "#e"?: u256[],
"#p"?: u256[], "#p"?: u256[],
"#t"?: string[], "#t"?: string[],
"#d"?: string[],
since?: number, since?: number,
until?: number, until?: number,
limit?: number limit?: number
@ -53,4 +54,4 @@ export type UserMetadata = {
nip05?: string, nip05?: string,
lud06?: string, lud06?: string,
lud16?: string lud16?: string
} }

View File

@ -8,6 +8,7 @@ import { hexToBech32 } from "../Util";
import { incDmInteraction } from "State/Login"; import { incDmInteraction } from "State/Login";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import NoteToSelf from "Element/NoteToSelf"; import NoteToSelf from "Element/NoteToSelf";
import useModeration from "Hooks/useModeration";
type DmChat = { type DmChat = {
pubkey: HexKey, pubkey: HexKey,
@ -20,10 +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 chats = useMemo(() => { const chats = useMemo(() => {
return extractChats(dms, myPubKey!); return extractChats(dms.filter(a => !isMuted(a.pubkey)), myPubKey!);
}, [dms, myPubKey, dmInteraction]); }, [dms, myPubKey, dmInteraction, muted]);
function noteToSelf(chat: DmChat) { function noteToSelf(chat: DmChat) {
return ( return (

View File

@ -10,6 +10,7 @@ import useProfile from "Feed/ProfileFeed";
import FollowButton from "Element/FollowButton"; import FollowButton from "Element/FollowButton";
import { extractLnAddress, parseId, hexToBech32 } from "Util"; import { extractLnAddress, parseId, hexToBech32 } from "Util";
import Avatar from "Element/Avatar"; import Avatar from "Element/Avatar";
import LogoutButton from "Element/LogoutButton";
import Timeline from "Element/Timeline"; import Timeline from "Element/Timeline";
import Text from 'Element/Text' import Text from 'Element/Text'
import LNURLTip from "Element/LNURLTip"; import LNURLTip from "Element/LNURLTip";
@ -17,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 MutedList from "Element/MutedList";
import FollowsList from "Element/FollowsList"; import FollowsList from "Element/FollowsList";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import { HexKey } from "Nostr"; import { HexKey } from "Nostr";
@ -26,7 +28,8 @@ enum ProfileTab {
Notes = "Notes", Notes = "Notes",
Reactions = "Reactions", Reactions = "Reactions",
Followers = "Followers", Followers = "Followers",
Follows = "Follows" Follows = "Follows",
Muted = "Muted"
}; };
export default function ProfilePage() { export default function ProfilePage() {
@ -35,6 +38,8 @@ export default function ProfilePage() {
const id = useMemo(() => parseId(params.id!), [params]); const id = useMemo(() => parseId(params.id!), [params]);
const user = useProfile(id)?.get(id); const user = useProfile(id)?.get(id);
const loggedOut = useSelector<RootState, boolean | undefined>(s => s.login.loggedOut); const loggedOut = useSelector<RootState, boolean | undefined>(s => s.login.loggedOut);
const muted = useSelector<RootState, HexKey[]>(s => s.login.muted);
const isMuted = useMemo(() => muted.includes(id), [muted, id])
const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey); const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows); const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
const isMe = loginPubKey === id; const isMe = loginPubKey === id;
@ -119,6 +124,9 @@ export default function ProfilePage() {
case ProfileTab.Followers: { case ProfileTab.Followers: {
return <FollowersList pubkey={id} /> return <FollowersList pubkey={id} />
} }
case ProfileTab.Muted: {
return <MutedList pubkey={id} />
}
} }
} }
@ -136,9 +144,12 @@ export default function ProfilePage() {
{username()} {username()}
<div className="profile-actions"> <div className="profile-actions">
{isMe ? ( {isMe ? (
<>
<LogoutButton />
<button type="button" onClick={() => navigate("/settings")}> <button type="button" onClick={() => navigate("/settings")}>
Settings Settings
</button> </button>
</>
) : ( ) : (
!loggedOut && ( !loggedOut && (
<> <>
@ -165,7 +176,7 @@ export default function ProfilePage() {
</div> </div>
</div> </div>
<div className="tabs"> <div className="tabs">
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(v => { {[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Muted].map(v => {
return <div className={`tab f-1${tab === v ? " active" : ""}`} key={v} onClick={() => setTab(v)}>{v}</div> return <div className={`tab f-1${tab === v ? " active" : ""}`} key={v} onClick={() => setTab(v)}>{v}</div>
})} })}
</div> </div>

View File

@ -2,7 +2,7 @@ import "./Profile.css";
import Nostrich from "nostrich.jpg"; import Nostrich from "nostrich.jpg";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faShop } from "@fortawesome/free-solid-svg-icons"; 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 useEventPublisher from "Feed/EventPublisher";
import useProfile from "Feed/ProfileFeed"; import useProfile from "Feed/ProfileFeed";
import VoidUpload from "Feed/VoidUpload"; import VoidUpload from "Feed/VoidUpload";
import { logout } from "State/Login"; import LogoutButton from "Element/LogoutButton";
import { hexToBech32, openFile } from "Util"; import { hexToBech32, openFile } from "Util";
import Copy from "Element/Copy"; import Copy from "Element/Copy";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
@ -20,7 +20,6 @@ export default function ProfileSettings() {
const navigate = useNavigate(); const navigate = useNavigate();
const id = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey); const id = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey); const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey);
const dispatch = useDispatch();
const user = useProfile(id)?.get(id || ""); const user = useProfile(id)?.get(id || "");
const publisher = useEventPublisher(); const publisher = useEventPublisher();
@ -143,7 +142,7 @@ export default function ProfileSettings() {
</div> </div>
<div className="form-group"> <div className="form-group">
<div> <div>
<button className="secondary" type="button" onClick={() => { dispatch(logout()); navigate("/"); }}>Logout</button> <LogoutButton />
</div> </div>
<div> <div>
<button type="button" onClick={() => saveProfile()}>Save</button> <button type="button" onClick={() => saveProfile()}>Save</button>

View File

@ -72,6 +72,16 @@ export interface LoginStore {
*/ */
follows: HexKey[], follows: HexKey[],
/**
* A list of pubkeys this user has muted
*/
muted: HexKey[],
/**
* Last seen mute list event timestamp
*/
lastMutedSeenAt: number,
/** /**
* Notifications for this login session * Notifications for this login session
*/ */
@ -105,6 +115,8 @@ const InitState = {
relays: {}, relays: {},
latestRelays: 0, latestRelays: 0,
follows: [], follows: [],
lastMutedSeenAt: 0,
muted: [],
notifications: [], notifications: [],
readNotifications: new Date().getTime(), readNotifications: new Date().getTime(),
dms: [], dms: [],
@ -207,6 +219,14 @@ const LoginSlice = createSlice({
state.follows = Array.from(existing); state.follows = Array.from(existing);
} }
}, },
setMuted(state, action: PayloadAction<{at: number, keys: HexKey[]}>) {
const { at, keys } = action.payload
if (at > state.lastMutedSeenAt) {
const muted = new Set([...keys])
state.muted = Array.from(muted)
state.lastMutedSeenAt = at
}
},
addNotifications: (state, action: PayloadAction<TaggedRawEvent | TaggedRawEvent[]>) => { addNotifications: (state, action: PayloadAction<TaggedRawEvent | TaggedRawEvent[]>) => {
let n = action.payload; let n = action.payload;
if (!Array.isArray(n)) { if (!Array.isArray(n)) {
@ -273,10 +293,11 @@ export const {
removeRelay, removeRelay,
setFollows, setFollows,
addNotifications, addNotifications,
setMuted,
addDirectMessage, addDirectMessage,
incDmInteraction, incDmInteraction,
logout, logout,
markNotificationsRead, markNotificationsRead,
setPreferences setPreferences,
} = LoginSlice.actions; } = LoginSlice.actions;
export const reducer = LoginSlice.reducer; export const reducer = LoginSlice.reducer;

View File

@ -124,10 +124,6 @@ button:disabled {
cursor: not-allowed; cursor: not-allowed;
color: var(--gray); color: var(--gray);
} }
.light button:disabled {
color: var(--font-color);
}
button:hover { button:hover {
background-color: var(--font-color); background-color: var(--font-color);
@ -487,3 +483,7 @@ body.scroll-lock {
.bold { .bold {
font-weight: 700; font-weight: 700;
} }
.blurred {
filter: blur(5px);
}