feat: WoT filter

This commit is contained in:
2024-09-18 13:07:53 +01:00
parent 0d5dcd43a4
commit b457b7b536
14 changed files with 144 additions and 81 deletions

View File

@ -1,4 +1,5 @@
import { EventKind, NostrLink, TaggedNostrEvent } from "@snort/system"; import { EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
import { WorkerRelayInterface } from "@snort/worker-relay";
import classNames from "classnames"; import classNames from "classnames";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useInView } from "react-intersection-observer"; import { useInView } from "react-intersection-observer";
@ -41,7 +42,7 @@ export function Note(props: NoteProps) {
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "2000px" }); const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "2000px" });
const { ref: setSeenAtRef, inView: setSeenAtInView } = useInView({ rootMargin: "0px", threshold: 1 }); const { ref: setSeenAtRef, inView: setSeenAtInView } = useInView({ rootMargin: "0px", threshold: 1 });
const [showTranslation, setShowTranslation] = useState(true); const [showTranslation, setShowTranslation] = useState(true);
const [translated, setTranslated] = useState<NoteTranslation>(translationCache.get(ev.id)); const [translated, setTranslated] = useState<NoteTranslation | null>(translationCache.get(ev.id));
const cachedSetTranslated = useCallback( const cachedSetTranslated = useCallback(
(translation: NoteTranslation) => { (translation: NoteTranslation) => {
translationCache.set(ev.id, translation); translationCache.set(ev.id, translation);
@ -54,7 +55,9 @@ export function Note(props: NoteProps) {
let timeout: ReturnType<typeof setTimeout>; let timeout: ReturnType<typeof setTimeout>;
if (setSeenAtInView) { if (setSeenAtInView) {
timeout = setTimeout(() => { timeout = setTimeout(() => {
Relay.setEventMetadata(ev.id, { seen_at: Math.round(Date.now() / 1000) }); if (Relay instanceof WorkerRelayInterface) {
Relay.setEventMetadata(ev.id, { seen_at: Math.round(Date.now() / 1000) });
}
}, 1000); }, 1000);
} }
return () => clearTimeout(timeout); return () => clearTimeout(timeout);

View File

@ -9,6 +9,7 @@ import { ReplyButton } from "@/Components/Event/Note/NoteFooter/ReplyButton";
import { RepostButton } from "@/Components/Event/Note/NoteFooter/RepostButton"; import { RepostButton } from "@/Components/Event/Note/NoteFooter/RepostButton";
import ReactionsModal from "@/Components/Event/Note/ReactionsModal"; import ReactionsModal from "@/Components/Event/Note/ReactionsModal";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import useModeration from "@/Hooks/useModeration";
import usePreferences from "@/Hooks/usePreferences"; import usePreferences from "@/Hooks/usePreferences";
export interface NoteFooterProps { export interface NoteFooterProps {
@ -20,9 +21,13 @@ export default function NoteFooter(props: NoteFooterProps) {
const { ev } = props; const { ev } = props;
const link = useMemo(() => NostrLink.fromEvent(ev), [ev.id]); const link = useMemo(() => NostrLink.fromEvent(ev), [ev.id]);
const [showReactions, setShowReactions] = useState(false); const [showReactions, setShowReactions] = useState(false);
const { isMuted } = useModeration();
const related = useReactions("reactions", link); const related = useReactions("reactions", link);
const { replies, reactions, zaps, reposts } = useEventReactions(link, related); const { replies, reactions, zaps, reposts } = useEventReactions(
link,
related.filter(a => !isMuted(a.pubkey)),
);
const { positive } = reactions; const { positive } = reactions;
const readonly = useLogin(s => s.readonly); const readonly = useLogin(s => s.readonly);

View File

@ -1,21 +1,15 @@
import "../EventComponent.css"; import classNames from "classnames";
import { FormattedMessage } from "react-intl";
import ProfileImage from "@/Components/User/ProfileImage";
interface NoteGhostProps { interface NoteGhostProps {
className?: string; className?: string;
children: React.ReactNode; link: string;
} }
export default function NoteGhost(props: NoteGhostProps) { export default function NoteGhost(props: NoteGhostProps) {
const className = `note card ${props.className ?? ""}`;
return ( return (
<div className={className}> <div className={classNames("p bb", props.className)}>
<div className="header"> <FormattedMessage defaultMessage="Loading note: {id}" values={{ id: props.link }} />
<ProfileImage pubkey="" />
</div>
<div className="body">{props.children}</div>
<div className="footer"></div>
</div> </div>
); );
} }

View File

@ -57,8 +57,6 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
waitUntilInView={false} waitUntilInView={false}
/> />
); );
} else {
return <NoteGhost className={className}>Loading thread root.. ({thread.data?.length} notes loaded)</NoteGhost>;
} }
} }
@ -75,16 +73,18 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
function renderCurrent() { function renderCurrent() {
if (thread.current) { if (thread.current) {
const note = thread.data.find(n => n.id === thread.current); const note = thread.data.find(n => n.id === thread.current);
return ( if (note) {
note && ( return (
<Note <Note
data={note} data={note}
options={{ showReactionsLink: true, showMediaSpotlight: true }} options={{ showReactionsLink: true, showMediaSpotlight: true }}
threadChains={thread.chains} threadChains={thread.chains}
onClick={navigateThread} onClick={navigateThread}
/> />
) );
); } else {
return <NoteGhost link={thread.current} />;
}
} }
} }
@ -100,7 +100,6 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
const parentText = formatMessage({ const parentText = formatMessage({
defaultMessage: "Parent", defaultMessage: "Parent",
id: "ADmfQT",
description: "Link to parent note in thread", description: "Link to parent note in thread",
}); });
@ -134,10 +133,15 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
{thread.root && renderRoot(thread.root)} {thread.root && renderRoot(thread.root)}
{thread.root && renderChain(chainKey(thread.root))} {thread.root && renderChain(chainKey(thread.root))}
{!thread.root && renderCurrent()} {!thread.root && renderCurrent()}
{!thread.root && !thread.current && ( {thread.mutedData.length > 0 && (
<NoteGhost> <div className="p br b mx-2 my-3 bg-gray-ultradark text-gray-light font-medium cursor-pointer">
<FormattedMessage defaultMessage="Looking up thread..." /> <FormattedMessage
</NoteGhost> defaultMessage="{n} notes have been muted"
values={{
n: thread.mutedData.length,
}}
/>
</div>
)} )}
</div> </div>
</> </>

View File

@ -1,15 +1,21 @@
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import { UserRelays } from "@/Cache"; import { UserRelays } from "@/Cache";
import useWoT from "@/Hooks/useWoT";
import { getRelayName } from "@/Utils"; import { getRelayName } from "@/Utils";
export function UserDebug({ pubkey }: { pubkey: string }) { export function UserDebug({ pubkey }: { pubkey: string }) {
const profile = useUserProfile(pubkey); const profile = useUserProfile(pubkey);
const relays = UserRelays.getFromCache(pubkey); const relays = UserRelays.getFromCache(pubkey);
const wot = useWoT();
return ( return (
<div className="text-xs"> <div className="text-xs">
<div className="flex flex-col overflow-wrap"> <div className="flex flex-col overflow-wrap">
<div className="flex justify-between gap-1">
<div>WoT Distance:</div>
<div>{wot.followDistance(pubkey)}</div>
</div>
{Object.entries(profile ?? {}).map(([k, v]) => { {Object.entries(profile ?? {}).map(([k, v]) => {
let vv = <div>{v}</div>; let vv = <div>{v}</div>;

View File

@ -3,6 +3,8 @@ import { EventKind, NostrEvent, NostrLink, TaggedNostrEvent, ToNostrEventTag, Un
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import useWoT from "./useWoT";
export class MutedWordTag implements ToNostrEventTag { export class MutedWordTag implements ToNostrEventTag {
constructor(readonly word: string) {} constructor(readonly word: string) {}
equals(other: ToNostrEventTag): boolean { equals(other: ToNostrEventTag): boolean {
@ -16,10 +18,12 @@ export class MutedWordTag implements ToNostrEventTag {
export default function useModeration() { export default function useModeration() {
const { state } = useLogin(s => ({ v: s.state.version, state: s.state })); const { state } = useLogin(s => ({ v: s.state.version, state: s.state }));
const wot = useWoT();
function isMuted(id: string) { function isMuted(pubkey: string) {
const link = NostrLink.publicKey(id); const link = NostrLink.publicKey(pubkey);
return state.muted.some(a => a.equals(link)); const distance = wot.followDistance(pubkey);
return state.muted.some(a => a.equals(link)) || (state.appdata?.preferences.muteWithWoT && distance > 2);
} }
async function unmute(id: string) { async function unmute(id: string) {

View File

@ -1,11 +1,11 @@
import { TaggedNostrEvent } from "@snort/system"; import { socialGraphInstance, TaggedNostrEvent } from "@snort/system";
import { socialGraphInstance } from "@snort/system/dist/SocialGraph/SocialGraph";
export default function useWoT() { export default function useWoT() {
const sg = socialGraphInstance;
return { return {
sortEvents: (events: Array<TaggedNostrEvent>) => sortEvents: (events: Array<TaggedNostrEvent>) =>
events.sort((a, b) => sg.getFollowDistance(a.pubkey) - sg.getFollowDistance(b.pubkey)), events.sort(
followDistance: sg.getFollowDistance, (a, b) => socialGraphInstance.getFollowDistance(a.pubkey) - socialGraphInstance.getFollowDistance(b.pubkey),
),
followDistance: (pk: string) => socialGraphInstance.getFollowDistance(pk),
}; };
} }

View File

@ -182,6 +182,40 @@ const PreferencesPage = () => {
/> />
</div> </div>
</div> </div>
<div className="flex justify-between w-max">
<div className="flex flex-col g8">
<h4>
<FormattedMessage defaultMessage="WoT Filter" />
</h4>
<small>
<FormattedMessage defaultMessage="Mute notes from people who are outside your web of trust" />
</small>
</div>
<div>
<input
type="checkbox"
checked={pref.muteWithWoT}
onChange={e => setPref({ ...pref, muteWithWoT: e.target.checked })}
/>
</div>
</div>
<div className="flex justify-between">
<div className="flex flex-col g8">
<h4>
<FormattedMessage defaultMessage="Hide muted notes" />
</h4>
<small>
<FormattedMessage defaultMessage="Muted notes will not be shown" />
</small>
</div>
<div>
<input
type="checkbox"
checked={pref.hideMutedNotes}
onChange={e => setPref({ ...pref, hideMutedNotes: e.target.checked })}
/>
</div>
</div>
<div className="flex justify-between w-max"> <div className="flex justify-between w-max">
<div className="flex flex-col g8"> <div className="flex flex-col g8">
<h4> <h4>
@ -471,23 +505,7 @@ const PreferencesPage = () => {
/> />
</div> </div>
</div> </div>
<div className="flex justify-between">
<div className="flex flex-col g8">
<h4>
<FormattedMessage defaultMessage="Hide muted notes" />
</h4>
<small>
<FormattedMessage defaultMessage="Muted notes will not be shown" />
</small>
</div>
<div>
<input
type="checkbox"
checked={pref.hideMutedNotes}
onChange={e => setPref({ ...pref, hideMutedNotes: e.target.checked })}
/>
</div>
</div>
<AsyncButton onClick={() => update(pref)}> <AsyncButton onClick={() => update(pref)}>
<FormattedMessage defaultMessage="Save" /> <FormattedMessage defaultMessage="Save" />
</AsyncButton> </AsyncButton>

View File

@ -102,6 +102,11 @@ export interface UserPreferences {
* Show posts with content warning * Show posts with content warning
*/ */
showContentWarningPosts: boolean; showContentWarningPosts: boolean;
/**
* Mute notes outside your WoT
*/
muteWithWoT: boolean;
} }
export const DefaultPreferences = { export const DefaultPreferences = {
@ -122,5 +127,6 @@ export const DefaultPreferences = {
checkSigs: true, checkSigs: true,
autoTranslate: true, autoTranslate: true,
hideMutedNotes: false, hideMutedNotes: false,
muteWithWoT: false,
showContentWarningPosts: false, showContentWarningPosts: false,
} as UserPreferences; } as UserPreferences;

View File

@ -7,6 +7,7 @@ export interface ThreadContextState {
root?: TaggedNostrEvent; root?: TaggedNostrEvent;
chains: Map<string, Array<TaggedNostrEvent>>; chains: Map<string, Array<TaggedNostrEvent>>;
data: Array<TaggedNostrEvent>; data: Array<TaggedNostrEvent>;
mutedData: Array<TaggedNostrEvent>;
setCurrent: (i: string) => void; setCurrent: (i: string) => void;
} }

View File

@ -1,5 +1,5 @@
import { unwrap } from "@snort/shared"; import { unwrap } from "@snort/shared";
import { NostrLink, TaggedNostrEvent, u256 } from "@snort/system"; import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { ReactNode, useMemo, useState } from "react"; import { ReactNode, useMemo, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
@ -11,52 +11,53 @@ import { ThreadContext, ThreadContextState } from "@/Utils/Thread/ThreadContext"
export function ThreadContextWrapper({ link, children }: { link: NostrLink; children?: ReactNode }) { export function ThreadContextWrapper({ link, children }: { link: NostrLink; children?: ReactNode }) {
const location = useLocation(); const location = useLocation();
const [currentId, setCurrentId] = useState(unwrap(link.toEventTag())[1]); const [currentId, setCurrentId] = useState(unwrap(link.toEventTag())[1]);
const feed = useThreadFeed(link); const feedData = useThreadFeed(link);
const { isMuted } = useModeration(); const { isMuted } = useModeration();
const chains = useMemo(() => { function threadChains(notes: Array<TaggedNostrEvent>) {
const chains = new Map<u256, Array<TaggedNostrEvent>>(); const chains = new Map<string, Array<TaggedNostrEvent>>();
if (feed) { notes
feed .filter(a => !isMuted(a.pubkey))
?.filter(a => !isMuted(a.pubkey)) .forEach(v => {
.forEach(v => { const replyTo = replyChainKey(v);
const replyTo = replyChainKey(v); if (replyTo) {
if (replyTo) { if (!chains.has(replyTo)) {
if (!chains.has(replyTo)) { chains.set(replyTo, [v]);
chains.set(replyTo, [v]); } else {
} else { unwrap(chains.get(replyTo)).push(v);
unwrap(chains.get(replyTo)).push(v);
}
} }
}); }
} });
return chains; return chains;
}, [feed, isMuted]); }
// Root is the parent of the current note or the current note if its a root note or the root of the thread // Root is the parent of the current note or
// the current note if its a root note or
// the root of the thread
const root = useMemo(() => { const root = useMemo(() => {
const currentNote = const currentNote =
feed?.find(a => chainKey(a) === currentId) ?? feedData.find(a => chainKey(a) === currentId) ??
(location.state && "sig" in location.state ? (location.state as TaggedNostrEvent) : undefined); (location.state && "sig" in location.state ? (location.state as TaggedNostrEvent) : undefined);
if (currentNote) { if (currentNote) {
const key = replyChainKey(currentNote); const key = replyChainKey(currentNote);
if (key) { if (key) {
return feed?.find(a => chainKey(a) === key); return feedData.find(a => chainKey(a) === key);
} else { } else {
return currentNote; return currentNote;
} }
} }
}, [feed, location.state, currentId]); }, [feedData, location.state, currentId]);
const ctxValue = useMemo<ThreadContextState>(() => { const ctxValue = useMemo<ThreadContextState>(() => {
return { return {
current: currentId, current: currentId,
root, root,
chains, chains: threadChains(feedData.filter(a => !isMuted(a.pubkey))),
data: feed, data: feedData,
mutedData: feedData.filter(a => isMuted(a.pubkey)),
setCurrent: v => setCurrentId(v), setCurrent: v => setCurrentId(v),
}; };
}, [currentId, root, chains, feed]); }, [currentId, root, feedData]);
return <ThreadContext.Provider value={ctxValue}>{children}</ThreadContext.Provider>; return <ThreadContext.Provider value={ctxValue}>{children}</ThreadContext.Provider>;
} }

View File

@ -2,7 +2,8 @@ import "./index.css";
import "@szhsin/react-menu/dist/index.css"; import "@szhsin/react-menu/dist/index.css";
import "@/assets/fonts/inter.css"; import "@/assets/fonts/inter.css";
import { unixNow } from "@snort/shared"; import { unixNow, unixNowMs } from "@snort/shared";
import { socialGraphInstance } from "@snort/system";
import { SnortContext } from "@snort/system-react"; import { SnortContext } from "@snort/system-react";
import { StrictMode } from "react"; import { StrictMode } from "react";
import * as ReactDOM from "react-dom/client"; import * as ReactDOM from "react-dom/client";
@ -60,7 +61,15 @@ async function initSite() {
const login = LoginStore.snapshot(); const login = LoginStore.snapshot();
preload(login.state.follows).then(async () => { preload(login.state.follows).then(async () => {
queueMicrotask(() => System.PreloadSocialGraph(login.state.follows)); queueMicrotask(async () => {
const start = unixNowMs();
await System.PreloadSocialGraph(login.state.follows);
console.debug(
`Social graph loaded in ${(unixNowMs() - start).toFixed(2)}ms, followDistances=${
socialGraphInstance.followDistanceByUser.size
}`,
);
});
for (const ev of UserCache.snapshot()) { for (const ev of UserCache.snapshot()) {
try { try {

View File

@ -342,6 +342,9 @@
"7hp70g": { "7hp70g": {
"defaultMessage": "NIP-05" "defaultMessage": "NIP-05"
}, },
"7nAz/z": {
"defaultMessage": "Mute notes from people who are outside your web of trust"
},
"7pFGAQ": { "7pFGAQ": {
"defaultMessage": "Close Relays" "defaultMessage": "Close Relays"
}, },
@ -753,9 +756,6 @@
"J2HeQ+": { "J2HeQ+": {
"defaultMessage": "Use commas to separate words e.g. word1, word2, word3" "defaultMessage": "Use commas to separate words e.g. word1, word2, word3"
}, },
"JA+tz3": {
"defaultMessage": "Looking up thread..."
},
"JCIgkj": { "JCIgkj": {
"defaultMessage": "Username" "defaultMessage": "Username"
}, },
@ -1033,6 +1033,9 @@
"Rs4kCE": { "Rs4kCE": {
"defaultMessage": "Bookmark" "defaultMessage": "Bookmark"
}, },
"S/NV2G": {
"defaultMessage": "Loading note: {id}"
},
"SFuk1v": { "SFuk1v": {
"defaultMessage": "Permissions" "defaultMessage": "Permissions"
}, },
@ -1389,6 +1392,9 @@
"d+6YsV": { "d+6YsV": {
"defaultMessage": "Lists to mute:" "defaultMessage": "Lists to mute:"
}, },
"d0qim7": {
"defaultMessage": "WoT Filter"
},
"d2ebEu": { "d2ebEu": {
"defaultMessage": "Not Subscribed to Push" "defaultMessage": "Not Subscribed to Push"
}, },
@ -1713,6 +1719,9 @@
"lvlPhZ": { "lvlPhZ": {
"defaultMessage": "Pay Invoice" "defaultMessage": "Pay Invoice"
}, },
"mCEKiZ": {
"defaultMessage": "{n} notes have been muted"
},
"mErPop": { "mErPop": {
"defaultMessage": "It looks like you dont have any, check {link} to buy one!" "defaultMessage": "It looks like you dont have any, check {link} to buy one!"
}, },

View File

@ -113,6 +113,7 @@
"7UOvbT": "Offline", "7UOvbT": "Offline",
"7YkSA2": "Community Leader", "7YkSA2": "Community Leader",
"7hp70g": "NIP-05", "7hp70g": "NIP-05",
"7nAz/z": "Mute notes from people who are outside your web of trust",
"7pFGAQ": "Close Relays", "7pFGAQ": "Close Relays",
"8/vBbP": "Reposts ({n})", "8/vBbP": "Reposts ({n})",
"89q5wc": "Confirm Reposts", "89q5wc": "Confirm Reposts",
@ -249,7 +250,6 @@
"J+dIsA": "Subscriptions", "J+dIsA": "Subscriptions",
"J1iLmb": "Notifications Not Allowed", "J1iLmb": "Notifications Not Allowed",
"J2HeQ+": "Use commas to separate words e.g. word1, word2, word3", "J2HeQ+": "Use commas to separate words e.g. word1, word2, word3",
"JA+tz3": "Looking up thread...",
"JCIgkj": "Username", "JCIgkj": "Username",
"JGrt9q": "Send sats to {name}", "JGrt9q": "Send sats to {name}",
"JHEHCk": "Zaps ({n})", "JHEHCk": "Zaps ({n})",
@ -342,6 +342,7 @@
"RmxSZo": "Data Vending Machines", "RmxSZo": "Data Vending Machines",
"RoOyAh": "Relays", "RoOyAh": "Relays",
"Rs4kCE": "Bookmark", "Rs4kCE": "Bookmark",
"S/NV2G": "Loading note: {id}",
"SFuk1v": "Permissions", "SFuk1v": "Permissions",
"SLZGPn": "Enter a pin to encrypt your private key, you must enter this pin every time you open {site}.", "SLZGPn": "Enter a pin to encrypt your private key, you must enter this pin every time you open {site}.",
"SMO+on": "Send zap to {name}", "SMO+on": "Send zap to {name}",
@ -460,6 +461,7 @@
"cw1Ftc": "Live Activities", "cw1Ftc": "Live Activities",
"cyR7Kh": "Back", "cyR7Kh": "Back",
"d+6YsV": "Lists to mute:", "d+6YsV": "Lists to mute:",
"d0qim7": "WoT Filter",
"d2ebEu": "Not Subscribed to Push", "d2ebEu": "Not Subscribed to Push",
"d7d0/x": "LN Address", "d7d0/x": "LN Address",
"dK2CcV": "The public key is like your username, you can share it with anyone.", "dK2CcV": "The public key is like your username, you can share it with anyone.",
@ -568,6 +570,7 @@
"lnaT9F": "Following {n}", "lnaT9F": "Following {n}",
"lsNFM1": "Click to load content from {link}", "lsNFM1": "Click to load content from {link}",
"lvlPhZ": "Pay Invoice", "lvlPhZ": "Pay Invoice",
"mCEKiZ": "{n} notes have been muted",
"mErPop": "It looks like you dont have any, check {link} to buy one!", "mErPop": "It looks like you dont have any, check {link} to buy one!",
"mFtdYh": "{type} Worker Relay", "mFtdYh": "{type} Worker Relay",
"mKAr6h": "Follow all", "mKAr6h": "Follow all",