From 0e9ca7e2e3df4a5d0b1c0e35b128921348d4a691 Mon Sep 17 00:00:00 2001 From: Kieran Date: Sun, 24 Sep 2023 13:33:12 +0100 Subject: [PATCH] Muted words: phase 1 --- packages/app/public/icons.svg | 3 ++ packages/app/src/Element/Note.css | 4 -- packages/app/src/Element/Note.tsx | 11 +++-- packages/app/src/Feed/LoginFeed.ts | 12 ++++- packages/app/src/Hooks/useModeration.tsx | 14 +++++- packages/app/src/Login/Functions.ts | 11 ++++- packages/app/src/Login/LoginSession.ts | 9 ++++ packages/app/src/Login/MultiAccountStore.ts | 6 +++ packages/app/src/Pages/SettingsPage.tsx | 5 +++ .../app/src/Pages/settings/Moderation.tsx | 44 +++++++++++++++++++ packages/app/src/Pages/settings/Root.tsx | 5 +++ packages/app/src/index.css | 4 ++ packages/system/src/event-kind.ts | 1 + packages/system/src/event-publisher.ts | 20 +++++++++ packages/system/src/index.ts | 23 ++++++++++ packages/system/src/signer.ts | 28 ++---------- 16 files changed, 161 insertions(+), 39 deletions(-) create mode 100644 packages/app/src/Pages/settings/Moderation.tsx diff --git a/packages/app/public/icons.svg b/packages/app/public/icons.svg index 8da33944..f7e27883 100644 --- a/packages/app/public/icons.svg +++ b/packages/app/public/icons.svg @@ -339,6 +339,9 @@ + + + \ No newline at end of file diff --git a/packages/app/src/Element/Note.css b/packages/app/src/Element/Note.css index 9c60d8db..bbefbb49 100644 --- a/packages/app/src/Element/Note.css +++ b/packages/app/src/Element/Note.css @@ -164,10 +164,6 @@ min-height: unset; } -.hidden-note button { - max-height: 30px; -} - .expand-note { padding: 0 0 16px 0; font-weight: 400; diff --git a/packages/app/src/Element/Note.tsx b/packages/app/src/Element/Note.tsx index e575879c..ea23f6c6 100644 --- a/packages/app/src/Element/Note.tsx +++ b/packages/app/src/Element/Note.tsx @@ -67,14 +67,14 @@ export interface NoteProps { const HiddenNote = ({ children }: { children: React.ReactNode }) => { const [show, setShow] = useState(false); return show ? ( - <>{children} + children ) : (

- +

-
@@ -116,8 +116,7 @@ export function NoteInner(props: NoteProps) { const navigate = useNavigate(); const [showReactions, setShowReactions] = useState(false); const deletions = useMemo(() => getReactions(related, ev.id, EventKind.Deletion), [related]); - const { isMuted } = useModeration(); - const isOpMuted = isMuted(ev?.pubkey); + const { isEventMuted } = useModeration(); const { ref, inView } = useInView({ triggerOnce: true }); const login = useLogin(); const { pinned, bookmarked } = login; @@ -466,5 +465,5 @@ export function NoteInner(props: NoteProps) {
); - return !ignoreModeration && isOpMuted ? {note} : note; + return !ignoreModeration && isEventMuted(ev) ? {note} : note; } diff --git a/packages/app/src/Feed/LoginFeed.ts b/packages/app/src/Feed/LoginFeed.ts index 573045e3..38719aef 100644 --- a/packages/app/src/Feed/LoginFeed.ts +++ b/packages/app/src/Feed/LoginFeed.ts @@ -8,7 +8,7 @@ import useEventPublisher from "Hooks/useEventPublisher"; import { getMutedKeys } from "Feed/MuteList"; import useModeration from "Hooks/useModeration"; import useLogin from "Hooks/useLogin"; -import { addSubscription, setBlocked, setBookmarked, setFollows, setMuted, setPinned, setRelays, setTags } from "Login"; +import { SnortAppData, addSubscription, setAppData, setBlocked, setBookmarked, setFollows, setMuted, setPinned, setRelays, setTags } from "Login"; import { SnortPubKey } from "Const"; import { SubscriptionEvent } from "Subscription"; import useRelaysFeedFollows from "./RelaysFeedFollows"; @@ -38,7 +38,9 @@ export default function useLoginFeed() { leaveOpen: true, }); b.withFilter().authors([pubKey]).kinds([EventKind.ContactList]); + b.withFilter().authors([pubKey]).kinds([EventKind.AppData]).tag("d", ["snort"]); b.withFilter() + .relay("wss://relay.snort.social") .kinds([EventKind.SnortSubscriptions]) .authors([bech32ToHex(SnortPubKey)]) .tag("p", [pubKey]) @@ -97,6 +99,14 @@ export default function useLoginFeed() { } }), ).then(a => addSubscription(login, ...a.filter(a => a !== undefined).map(unwrap))); + + const appData = getNewest(loginFeed.data.filter(a => a.kind === EventKind.AppData)); + if(appData) { + publisher.decryptGeneric(appData.content, appData.pubkey).then(d => { + setAppData(login, JSON.parse(d) as SnortAppData, appData.created_at * 1000); + }) + } + } }, [loginFeed, publisher]); diff --git a/packages/app/src/Hooks/useModeration.tsx b/packages/app/src/Hooks/useModeration.tsx index 4376e251..d07dc355 100644 --- a/packages/app/src/Hooks/useModeration.tsx +++ b/packages/app/src/Hooks/useModeration.tsx @@ -1,4 +1,4 @@ -import { HexKey } from "@snort/system"; +import { HexKey, TaggedNostrEvent } from "@snort/system"; import useEventPublisher from "Hooks/useEventPublisher"; import useLogin from "Hooks/useLogin"; import { setBlocked, setMuted } from "Login"; @@ -7,7 +7,7 @@ import { System } from "index"; export default function useModeration() { const login = useLogin(); - const { muted, blocked } = login; + const { muted, blocked, appData } = login; const publisher = useEventPublisher(); async function setMutedList(pub: HexKey[], priv: HexKey[]) { @@ -57,6 +57,14 @@ export default function useModeration() { setMuted(login, newMuted, ts); } + function isMutedWord(word: string) { + return appData.item.mutedWords.includes(word.toLowerCase()); + } + + function isEventMuted(ev: TaggedNostrEvent) { + return isMuted(ev.pubkey) || appData.item.mutedWords.some(w => ev.content.toLowerCase().includes(w)); + } + return { muted: muted.item, mute, @@ -67,5 +75,7 @@ export default function useModeration() { block, unblock, isBlocked, + isMutedWord, + isEventMuted }; } diff --git a/packages/app/src/Login/Functions.ts b/packages/app/src/Login/Functions.ts index 3bca8b87..6cf14f10 100644 --- a/packages/app/src/Login/Functions.ts +++ b/packages/app/src/Login/Functions.ts @@ -4,7 +4,7 @@ import * as secp from "@noble/curves/secp256k1"; import * as utils from "@noble/curves/abstract/utils"; import { DefaultRelays, SnortPubKey } from "Const"; -import { LoginStore, UserPreferences, LoginSession, LoginSessionType } from "Login"; +import { LoginStore, UserPreferences, LoginSession, LoginSessionType, SnortAppData } from "Login"; import { generateBip39Entropy, entropyToPrivateKey } from "nip6"; import { bech32ToHex, dedupeById, randomSample, sanitizeRelayUrl, unwrap } from "SnortUtils"; import { SubscriptionEvent } from "Subscription"; @@ -157,6 +157,15 @@ export function setBookmarked(state: LoginSession, bookmarked: Array, ts LoginStore.updateSession(state); } +export function setAppData(state: LoginSession, data: SnortAppData, ts: number) { + if(state.appData.timestamp >= ts) { + return; + } + state.appData.item = data; + state.appData.timestamp = ts; + LoginStore.updateSession(state); +} + export function addSubscription(state: LoginSession, ...subs: SubscriptionEvent[]) { const newSubs = dedupeById([...(state.subscriptions || []), ...subs]); if (newSubs.length !== state.subscriptions.length) { diff --git a/packages/app/src/Login/LoginSession.ts b/packages/app/src/Login/LoginSession.ts index 8ca41ce2..de469ab9 100644 --- a/packages/app/src/Login/LoginSession.ts +++ b/packages/app/src/Login/LoginSession.ts @@ -18,6 +18,10 @@ export enum LoginSessionType { Nip7os = "nip7_os", } +export interface SnortAppData { + mutedWords: Array +} + export interface LoginSession { /** * Unique ID to identify this session @@ -114,4 +118,9 @@ export interface LoginSession { * Remote signer relays (NIP-46) */ remoteSignerRelays?: Array; + + /** + * Snort application data + */ + appData: Newest; } diff --git a/packages/app/src/Login/MultiAccountStore.ts b/packages/app/src/Login/MultiAccountStore.ts index 2e857356..0d355e47 100644 --- a/packages/app/src/Login/MultiAccountStore.ts +++ b/packages/app/src/Login/MultiAccountStore.ts @@ -46,6 +46,12 @@ const LoggedOut = { latestNotification: 0, readNotifications: 0, subscriptions: [], + appData: { + item: { + mutedWords: [] + }, + timestamp: 0, + }, } as LoginSession; export class MultiAccountStore extends ExternalStore { diff --git a/packages/app/src/Pages/SettingsPage.tsx b/packages/app/src/Pages/SettingsPage.tsx index 4160ca7b..717fede6 100644 --- a/packages/app/src/Pages/SettingsPage.tsx +++ b/packages/app/src/Pages/SettingsPage.tsx @@ -9,6 +9,7 @@ import AccountsPage from "Pages/settings/Accounts"; import { WalletSettingsRoutes } from "Pages/settings/WalletSettings"; import { ManageHandleRoutes } from "Pages/settings/handle"; import ExportKeys from "Pages/settings/Keys"; +import { ModerationSettings } from "./settings/Moderation"; import messages from "./messages"; @@ -56,6 +57,10 @@ export const SettingsRoutes: RouteObject[] = [ path: "keys", element: , }, + { + path: "moderation", + element: , + }, ...ManageHandleRoutes, ...WalletSettingsRoutes, ], diff --git a/packages/app/src/Pages/settings/Moderation.tsx b/packages/app/src/Pages/settings/Moderation.tsx new file mode 100644 index 00000000..0e3b5713 --- /dev/null +++ b/packages/app/src/Pages/settings/Moderation.tsx @@ -0,0 +1,44 @@ +import { unixNowMs } from "@snort/shared"; +import useLogin from "Hooks/useLogin"; +import { setAppData } from "Login"; +import { appendDedupe } from "SnortUtils"; +import { useState } from "react"; +import { FormattedMessage } from "react-intl"; + +export function ModerationSettings() { + const login = useLogin(); + const [muteWord, setMuteWord] = useState(""); + + function addMutedWord() { + login.appData ??= { + item: { + mutedWords: [] + }, + timestamp: 0 + }; + setAppData(login, { + ...login.appData.item, + mutedWords: appendDedupe(login.appData.item.mutedWords, [muteWord]) + }, unixNowMs()); + setMuteWord(""); + } + return <> +

+ +

+
+
+ setMuteWord(e.target.value)} /> + +
+ {login.appData.item.mutedWords.map(v =>
+
{v}
+ +
)} +
+ +} \ No newline at end of file diff --git a/packages/app/src/Pages/settings/Root.tsx b/packages/app/src/Pages/settings/Root.tsx index 34cc52f2..1fcc8d4e 100644 --- a/packages/app/src/Pages/settings/Root.tsx +++ b/packages/app/src/Pages/settings/Root.tsx @@ -51,6 +51,11 @@ const SettingsIndex = () => { +
navigate("moderation")}> + + + +
navigate("handle")}> diff --git a/packages/app/src/index.css b/packages/app/src/index.css index c131f265..a038ce28 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -133,6 +133,10 @@ code { } } +.b { + border: 1px solid var(--border-color); +} + .bg-primary { background: var(--primary-gradient); } diff --git a/packages/system/src/event-kind.ts b/packages/system/src/event-kind.ts index 0a3eb1fd..466a4818 100644 --- a/packages/system/src/event-kind.ts +++ b/packages/system/src/event-kind.ts @@ -25,6 +25,7 @@ enum EventKind { Badge = 30009, // NIP-58 ProfileBadges = 30008, // NIP-58 LongFormTextNote = 30023, // NIP-23 + AppData = 30_078, // NIP-78 LiveEvent = 30311, // NIP-102 UserStatus = 30315, // NIP-38 ZapstrTrack = 31337, diff --git a/packages/system/src/event-publisher.ts b/packages/system/src/event-publisher.ts index 87201330..4c0743d3 100644 --- a/packages/system/src/event-publisher.ts +++ b/packages/system/src/event-publisher.ts @@ -3,11 +3,13 @@ import * as utils from "@noble/curves/abstract/utils"; import { unwrap, getPublicKey, unixNow } from "@snort/shared"; import { + decodeEncryptionPayload, EventKind, EventSigner, FullRelaySettings, HexKey, Lists, + MessageEncryptorVersion, NostrEvent, NostrLink, NotSignedNostrEvent, @@ -24,6 +26,7 @@ import { EventBuilder } from "./event-builder"; import { EventExt } from "./event-ext"; import { findTag } from "./utils"; import { Nip7Signer } from "./impl/nip7"; +import { base64 } from "@scure/base"; type EventBuilderHook = (ev: EventBuilder) => EventBuilder; @@ -269,6 +272,23 @@ export class EventPublisher { return await this.#sign(eb); } + /** + * Generic decryption using NIP-23 payload scheme + */ + async decryptGeneric(content: string, from: string) { + const pl = decodeEncryptionPayload(content); + switch(pl.v) { + case MessageEncryptorVersion.Nip4: { + const nip4Payload = `${base64.encode(pl.ciphertext)}?iv=${base64.encode(pl.nonce)}`; + return await this.#signer.nip4Decrypt(nip4Payload, from); + } + case MessageEncryptorVersion.XChaCha20: { + return await this.#signer.nip44Decrypt(content, from); + } + } + throw new Error("Not supported version"); + } + async decryptDm(note: NostrEvent) { if (note.kind === EventKind.SealedRumor) { const unseal = await this.unsealRumor(note); diff --git a/packages/system/src/index.ts b/packages/system/src/index.ts index e54c883d..38d69c33 100644 --- a/packages/system/src/index.ts +++ b/packages/system/src/index.ts @@ -6,6 +6,7 @@ import { NostrEvent, ReqFilter, TaggedNostrEvent } from "./nostr"; import { ProfileLoaderService } from "./profile-cache"; import { RelayCache } from "./gossip-model"; import { QueryOptimizer } from "./query-optimizer"; +import { base64 } from "@scure/base"; export * from "./nostr-system"; export { default as EventKind } from "./event-kind"; @@ -136,3 +137,25 @@ export interface MessageEncryptor { encryptData(plaintext: string, sharedSecet: Uint8Array): Promise | MessageEncryptorPayload; decryptData(payload: MessageEncryptorPayload, sharedSecet: Uint8Array): Promise | string; } + +export function decodeEncryptionPayload(p: string) { + if (p.startsWith("{") && p.endsWith("}")) { + const pj = JSON.parse(p) as { v: number; nonce: string; ciphertext: string }; + return { + v: pj.v, + nonce: base64.decode(pj.nonce), + ciphertext: base64.decode(pj.ciphertext), + } as MessageEncryptorPayload; + } else { + const buf = base64.decode(p); + return { + v: buf[0], + nonce: buf.subarray(1, 25), + ciphertext: buf.subarray(25), + } as MessageEncryptorPayload; + } +} + +export function encodeEncryptionPayload(p: MessageEncryptorPayload) { + return base64.encode(new Uint8Array([p.v, ...p.nonce, ...p.ciphertext])); +} \ No newline at end of file diff --git a/packages/system/src/signer.ts b/packages/system/src/signer.ts index 2ad765b8..b15bb378 100644 --- a/packages/system/src/signer.ts +++ b/packages/system/src/signer.ts @@ -3,7 +3,7 @@ import { getPublicKey } from "@snort/shared"; import { EventExt } from "./event-ext"; import { Nip4WebCryptoEncryptor } from "./impl/nip4"; import { XChaCha20Encryptor } from "./impl/nip44"; -import { MessageEncryptorPayload, MessageEncryptorVersion } from "./index"; +import { MessageEncryptorVersion, decodeEncryptionPayload, encodeEncryptionPayload } from "./index"; import { NostrEvent } from "./nostr"; import { base64 } from "@scure/base"; @@ -74,11 +74,11 @@ export class PrivateKeySigner implements EventSigner { const enc = new XChaCha20Encryptor(); const shared = enc.getSharedSecret(this.#privateKey, key); const data = enc.encryptData(content, shared); - return this.#encodePayload(data); + return encodeEncryptionPayload(data); } async nip44Decrypt(content: string, otherKey: string) { - const payload = this.#decodePayload(content); + const payload = decodeEncryptionPayload(content); if (payload.v !== MessageEncryptorVersion.XChaCha20) throw new Error("Invalid payload version"); const enc = new XChaCha20Encryptor(); @@ -86,28 +86,6 @@ export class PrivateKeySigner implements EventSigner { return enc.decryptData(payload, shared); } - #decodePayload(p: string) { - if (p.startsWith("{") && p.endsWith("}")) { - const pj = JSON.parse(p) as { v: number; nonce: string; ciphertext: string }; - return { - v: pj.v, - nonce: base64.decode(pj.nonce), - ciphertext: base64.decode(pj.ciphertext), - } as MessageEncryptorPayload; - } else { - const buf = base64.decode(p); - return { - v: buf[0], - nonce: buf.subarray(1, 25), - ciphertext: buf.subarray(25), - } as MessageEncryptorPayload; - } - } - - #encodePayload(p: MessageEncryptorPayload) { - return base64.encode(new Uint8Array([p.v, ...p.nonce, ...p.ciphertext])); - } - sign(ev: NostrEvent): Promise { EventExt.sign(ev, this.#privateKey); return Promise.resolve(ev);