Muted words: phase 1
continuous-integration/drone/push Build is failing Details

This commit is contained in:
Kieran 2023-09-24 13:33:12 +01:00
parent 4d629f5087
commit 0e9ca7e2e3
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
16 changed files with 161 additions and 39 deletions

View File

@ -339,6 +339,9 @@
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 12.5008C9 10.8439 10.3431 9.50076 12 9.50076C13.6569 9.50076 15 10.8439 15 12.5008C15 14.1576 13.6569 15.5008 12 15.5008C10.3431 15.5008 9 14.1576 9 12.5008Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="shield-tick" viewBox="0 0 24 24" fill="none">
<path d="M9 11.5L11 13.5L15.5 8.99999M20 12C20 16.9084 14.646 20.4784 12.698 21.6149C12.4766 21.744 12.3659 21.8086 12.2097 21.8421C12.0884 21.8681 11.9116 21.8681 11.7903 21.8421C11.6341 21.8086 11.5234 21.744 11.302 21.6149C9.35396 20.4784 4 16.9084 4 12V7.21759C4 6.41808 4 6.01833 4.13076 5.6747C4.24627 5.37113 4.43398 5.10027 4.67766 4.88552C4.9535 4.64243 5.3278 4.50207 6.0764 4.22134L11.4382 2.21067C11.6461 2.13271 11.75 2.09373 11.857 2.07827C11.9518 2.06457 12.0482 2.06457 12.143 2.07827C12.25 2.09373 12.3539 2.13271 12.5618 2.21067L17.9236 4.22134C18.6722 4.50207 19.0465 4.64243 19.3223 4.88552C19.566 5.10027 19.7537 5.37113 19.8692 5.6747C20 6.01833 20 6.41808 20 7.21759V12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@ -164,10 +164,6 @@
min-height: unset;
}
.hidden-note button {
max-height: 30px;
}
.expand-note {
padding: 0 0 16px 0;
font-weight: 400;

View File

@ -67,14 +67,14 @@ export interface NoteProps {
const HiddenNote = ({ children }: { children: React.ReactNode }) => {
const [show, setShow] = useState(false);
return show ? (
<>{children}</>
children
) : (
<div className="card note hidden-note">
<div className="header">
<p>
<FormattedMessage {...messages.MutedAuthor} />
<FormattedMessage defaultMessage="This note has been muted" />
</p>
<button onClick={() => setShow(true)}>
<button type="button" onClick={() => setShow(true)}>
<FormattedMessage {...messages.Show} />
</button>
</div>
@ -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) {
</div>
);
return !ignoreModeration && isOpMuted ? <HiddenNote>{note}</HiddenNote> : note;
return !ignoreModeration && isEventMuted(ev) ? <HiddenNote>{note}</HiddenNote> : note;
}

View File

@ -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]);

View File

@ -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
};
}

View File

@ -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<string>, 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) {

View File

@ -18,6 +18,10 @@ export enum LoginSessionType {
Nip7os = "nip7_os",
}
export interface SnortAppData {
mutedWords: Array<string>
}
export interface LoginSession {
/**
* Unique ID to identify this session
@ -114,4 +118,9 @@ export interface LoginSession {
* Remote signer relays (NIP-46)
*/
remoteSignerRelays?: Array<string>;
/**
* Snort application data
*/
appData: Newest<SnortAppData>;
}

View File

@ -46,6 +46,12 @@ const LoggedOut = {
latestNotification: 0,
readNotifications: 0,
subscriptions: [],
appData: {
item: {
mutedWords: []
},
timestamp: 0,
},
} as LoginSession;
export class MultiAccountStore extends ExternalStore<LoginSession> {

View File

@ -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: <ExportKeys />,
},
{
path: "moderation",
element: <ModerationSettings />,
},
...ManageHandleRoutes,
...WalletSettingsRoutes,
],

View File

@ -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 <>
<h2>
<FormattedMessage defaultMessage="Muted Words" />
</h2>
<div className="flex-column g12">
<div className="flex g8">
<input type="text" placeholder="eg. crypto" className="w-max" value={muteWord} onChange={e => setMuteWord(e.target.value)} />
<button type="button" onClick={addMutedWord}>
<FormattedMessage defaultMessage="Add" />
</button>
</div>
{login.appData.item.mutedWords.map(v => <div className="p br b flex f-space">
<div>{v}</div>
<button type="button">
<FormattedMessage defaultMessage="Delete" />
</button>
</div>)}
</div>
</>
}

View File

@ -51,6 +51,11 @@ const SettingsIndex = () => {
<FormattedMessage defaultMessage="Export Keys" />
<Icon name="arrowFront" size={16} />
</div>
<div className="settings-row" onClick={() => navigate("moderation")}>
<Icon name="shield-tick" size={24} />
<FormattedMessage defaultMessage="Moderation" />
<Icon name="arrowFront" size={16} />
</div>
<div className="settings-row" onClick={() => navigate("handle")}>
<Icon name="badge" size={24} />
<FormattedMessage defaultMessage="Nostr Adddress" />

View File

@ -133,6 +133,10 @@ code {
}
}
.b {
border: 1px solid var(--border-color);
}
.bg-primary {
background: var(--primary-gradient);
}

View File

@ -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,

View File

@ -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);

View File

@ -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> | MessageEncryptorPayload;
decryptData(payload: MessageEncryptorPayload, sharedSecet: Uint8Array): Promise<string> | 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]));
}

View File

@ -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<NostrEvent> {
EventExt.sign(ev, this.#privateKey);
return Promise.resolve(ev);