feat: NIP-51
This commit is contained in:
parent
83df146716
commit
cfbf244955
@ -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
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
14
src/Element/LogoutButton.tsx
Normal file
14
src/Element/LogoutButton.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
21
src/Element/MuteButton.tsx
Normal file
21
src/Element/MuteButton.tsx
Normal 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
39
src/Element/MutedList.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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} />
|
||||||
|
@ -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} />
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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
51
src/Feed/MuteList.ts
Normal 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;
|
||||||
|
}
|
56
src/Hooks/useModeration.tsx
Normal file
56
src/Hooks/useModeration.tsx
Normal 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 }
|
||||||
|
}
|
@ -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;
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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 (
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user