feat: mute list

This commit is contained in:
2023-07-30 00:26:16 +02:00
parent 99e5b9688f
commit 0a5623e74f
12 changed files with 248 additions and 97 deletions

View File

@ -7,3 +7,11 @@ export const USER_EMOJIS = 10_030 as EventKind;
export const GOAL = 9041 as EventKind; export const GOAL = 9041 as EventKind;
export const USER_CARDS = 17_777 as EventKind; export const USER_CARDS = 17_777 as EventKind;
export const CARD = 37_777 as EventKind; export const CARD = 37_777 as EventKind;
export const MUTED = 10_000 as EventKind;
export const defaultRelays = {
"wss://relay.snort.social": { read: true, write: true },
"wss://nos.lol": { read: true, write: true },
"wss://relay.damus.io": { read: true, write: true },
"wss://nostr.wine": { read: true, write: true },
};

View File

@ -1,5 +1,6 @@
import "./emoji.css"; import "./emoji.css";
import { useMemo } from "react"; import { useMemo } from "react";
import { EmojiTag } from "types";
export type EmojiProps = { export type EmojiProps = {
name: string; name: string;
@ -10,8 +11,6 @@ export function Emoji({ name, url }: EmojiProps) {
return <img alt={name} src={url} className="emoji" />; return <img alt={name} src={url} className="emoji" />;
} }
export type EmojiTag = ["emoji", string, string];
export function Emojify({ export function Emojify({
content, content,
emoji, emoji,

View File

@ -1,19 +1,16 @@
import { EventKind } from "@snort/system"; import { EventKind } from "@snort/system";
import { useLogin } from "hooks/login"; import { useLogin } from "hooks/login";
import useFollows from "hooks/follows";
import AsyncButton from "element/async-button"; import AsyncButton from "element/async-button";
import { System } from "index"; import { System } from "index";
export function LoggedInFollowButton({ export function LoggedInFollowButton({
loggedIn,
pubkey, pubkey,
}: { }: {
loggedIn: string;
pubkey: string; pubkey: string;
}) { }) {
const login = useLogin(); const login = useLogin();
const following = useFollows(loggedIn, true); const tags = login?.follows.tags ?? []
const { tags, relays } = following ? following : { tags: [], relays: {} }; const relays = login?.relays
const follows = tags.filter((t) => t.at(0) === "p"); const follows = tags.filter((t) => t.at(0) === "p");
const isFollowing = follows.find((t) => t.at(1) === pubkey); const isFollowing = follows.find((t) => t.at(1) === pubkey);
@ -53,7 +50,7 @@ export function LoggedInFollowButton({
return ( return (
<AsyncButton <AsyncButton
disabled={!following} disabled={login.follows.timestamp === 0}
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
onClick={isFollowing ? unfollow : follow} onClick={isFollowing ? unfollow : follow}

View File

@ -79,10 +79,15 @@ export function LiveChat({
return () => System.ProfileLoader.UntrackMetadata(pubkeys); return () => System.ProfileLoader.UntrackMetadata(pubkeys);
}, [feed.zaps]); }, [feed.zaps]);
const userEmojiPacks = useEmoji(login?.pubkey); const mutedPubkeys = useMemo(() => {
return new Set(
login.muted.tags.filter((t) => t.at(0) === "p").map((t) => t.at(1)),
);
}, [login.muted.tags]);
const userEmojiPacks = login?.emojis ?? [];
const channelEmojiPacks = useEmoji(host); const channelEmojiPacks = useEmoji(host);
const allEmojiPacks = useMemo(() => { const allEmojiPacks = useMemo(() => {
return uniqBy(channelEmojiPacks.concat(userEmojiPacks), packId); return uniqBy(userEmojiPacks.concat(channelEmojiPacks), packId);
}, [userEmojiPacks, channelEmojiPacks]); }, [userEmojiPacks, channelEmojiPacks]);
const zaps = feed.zaps const zaps = feed.zaps
@ -105,6 +110,9 @@ export function LiveChat({
); );
} }
}, [ev]); }, [ev]);
const filteredEvents = useMemo(() => {
return events.filter((e) => !mutedPubkeys.has(e.pubkey));
}, [events, mutedPubkeys]);
return ( return (
<div className="live-chat" style={height ? { height: `${height}px` } : {}}> <div className="live-chat" style={height ? { height: `${height}px` } : {}}>
@ -135,7 +143,7 @@ export function LiveChat({
</div> </div>
)} )}
<div className="messages"> <div className="messages">
{events.map((a) => { {filteredEvents.map((a) => {
switch (a.kind) { switch (a.kind) {
case LIVE_STREAM_CHAT: { case LIVE_STREAM_CHAT: {
return ( return (

View File

@ -9,7 +9,6 @@ import { bytesToHex } from "@noble/curves/abstract/utils";
import { formatSats } from "../number"; import { formatSats } from "../number";
import { Icon } from "./icon"; import { Icon } from "./icon";
import AsyncButton from "./async-button"; import AsyncButton from "./async-button";
import { Relays } from "index";
import QrCode from "./qr-code"; import QrCode from "./qr-code";
import { useLogin } from "hooks/login"; import { useLogin } from "hooks/login";
import Copy from "./copy"; import Copy from "./copy";
@ -21,7 +20,7 @@ export interface LNURLLike {
getInvoice( getInvoice(
amountInSats: number, amountInSats: number,
comment?: string, comment?: string,
zap?: NostrEvent zap?: NostrEvent,
): Promise<{ pr?: string }>; ): Promise<{ pr?: string }>;
} }
@ -55,7 +54,7 @@ export function SendZaps({
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
const [invoice, setInvoice] = useState(""); const [invoice, setInvoice] = useState("");
const login = useLogin(); const login = useLogin();
const relays = Object.keys(login.relays);
const name = targetName ?? svc?.name; const name = targetName ?? svc?.name;
async function loadService(lnurl: string) { async function loadService(lnurl: string) {
const s = new LNURL(lnurl); const s = new LNURL(lnurl);
@ -78,7 +77,9 @@ export function SendZaps({
let pub = login?.publisher(); let pub = login?.publisher();
let isAnon = false; let isAnon = false;
if (!pub) { if (!pub) {
pub = EventPublisher.privateKey(bytesToHex(secp256k1.utils.randomPrivateKey())); pub = EventPublisher.privateKey(
bytesToHex(secp256k1.utils.randomPrivateKey()),
);
isAnon = true; isAnon = true;
} }
@ -88,7 +89,7 @@ export function SendZaps({
zap = await pub.zap( zap = await pub.zap(
amountInSats * 1000, amountInSats * 1000,
pubkey, pubkey,
Relays, relays,
undefined, undefined,
comment, comment,
(eb) => { (eb) => {
@ -102,7 +103,7 @@ export function SendZaps({
eb.tag(["anon", ""]); eb.tag(["anon", ""]);
} }
return eb; return eb;
} },
); );
} }
const invoice = await svc.getInvoice(amountInSats, comment, zap); const invoice = await svc.getInvoice(amountInSats, comment, zap);

View File

@ -1,3 +1,6 @@
import { useMemo } from "react";
import uniqBy from "lodash.uniqby";
import { import {
RequestBuilder, RequestBuilder,
ReplaceableNoteStore, ReplaceableNoteStore,
@ -6,23 +9,9 @@ import {
} from "@snort/system"; } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { System } from "index"; import { System } from "index";
import { useMemo } from "react";
import { findTag } from "utils"; import { findTag } from "utils";
import { EMOJI_PACK, USER_EMOJIS } from "const"; import { EMOJI_PACK, USER_EMOJIS } from "const";
import type { EmojiTag } from "../element/emoji"; import { EmojiPack } from "types";
import uniqBy from "lodash.uniqby";
export interface Emoji {
native?: string;
id?: string;
}
export interface EmojiPack {
address: string;
name: string;
author: string;
emojis: EmojiTag[];
}
function cleanShortcode(shortcode?: string) { function cleanShortcode(shortcode?: string) {
return shortcode?.replace(/\s+/g, "_").replace(/_$/, ""); return shortcode?.replace(/\s+/g, "_").replace(/_$/, "");
@ -44,22 +33,10 @@ export function packId(pack: EmojiPack): string {
return `${pack.author}:${pack.name}`; return `${pack.author}:${pack.name}`;
} }
export default function useEmoji(pubkey?: string) { export function useUserEmojiPacks(
const sub = useMemo(() => { pubkey?: string,
if (!pubkey) return null; userEmoji: { tags: string[][] },
const rb = new RequestBuilder(`emoji:${pubkey}`); ) {
rb.withFilter().authors([pubkey]).kinds([USER_EMOJIS]);
return rb;
}, [pubkey]);
const { data: userEmoji } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub,
);
const related = useMemo(() => { const related = useMemo(() => {
if (userEmoji) { if (userEmoji) {
return userEmoji.tags.filter( return userEmoji.tags.filter(
@ -106,3 +83,23 @@ export default function useEmoji(pubkey?: string) {
return emojis; return emojis;
} }
export default function useEmoji(pubkey?: string) {
const sub = useMemo(() => {
if (!pubkey) return null;
const rb = new RequestBuilder(`emoji:${pubkey}`);
rb.withFilter().authors([pubkey]).kinds([USER_EMOJIS]);
return rb;
}, [pubkey]);
const { data: userEmoji } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub,
);
const emojis = useUserEmojiPacks(pubkey, userEmoji ?? { tags: [] });
return emojis;
}

View File

@ -1,26 +0,0 @@
import { useMemo } from "react";
import { EventKind, ReplaceableNoteStore, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { System } from "index";
export default function useFollows(pubkey: string, leaveOpen = false) {
const sub = useMemo(() => {
const b = new RequestBuilder(`follows:${pubkey.slice(0, 12)}`);
b.withOptions({
leaveOpen,
})
.withFilter()
.authors([pubkey])
.kinds([EventKind.ContactList]);
return b;
}, [pubkey, leaveOpen]);
const { data } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub,
);
const relays = JSON.parse(data?.content.length > 0 ? data?.content : "{}");
return data ? { tags: data.tags, relays } : null;
}

View File

@ -1,17 +1,100 @@
import { Login } from "index"; import { useSyncExternalStore, useMemo, useState, useEffect } from "react";
import { getPublisher } from "login";
import { useSyncExternalStore } from "react"; import { EventKind, NoteCollection, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useUserEmojiPacks } from "hooks/emoji";
import { USER_EMOJIS } from "const";
import { System, Login } from "index";
import {
getPublisher,
setMuted,
setEmojis,
setFollows,
setRelays,
} from "login";
export function useLogin() { export function useLogin() {
const session = useSyncExternalStore( const session = useSyncExternalStore(
(c) => Login.hook(c), (c) => Login.hook(c),
() => Login.snapshot() () => Login.snapshot(),
); );
if (!session) return; if (!session) return;
return { return {
...session, ...session,
publisher: () => { publisher: () => {
return getPublisher(session); return getPublisher(session);
} },
} };
}
export function useLoginEvents(pubkey?: string, leaveOpen = false) {
const [userEmojis, setUserEmojis] = useState([]);
const session = useSyncExternalStore(
(c) => Login.hook(c),
() => Login.snapshot(),
);
useEffect(() => {
if (session) {
Object.entries(session.relays).forEach((params) => {
const [relay, settings] = params;
System.ConnectToRelay(relay, settings);
});
}
}, [session]);
const sub = useMemo(() => {
if (!pubkey) return null;
const b = new RequestBuilder(`login:${pubkey.slice(0, 12)}`);
b.withOptions({
leaveOpen,
})
.withFilter()
.authors([pubkey])
.kinds([
EventKind.ContactList,
EventKind.Relays,
10_000 as EventKind,
USER_EMOJIS,
]);
return b;
}, [pubkey, leaveOpen]);
const { data } = useRequestBuilder<NoteCollection>(
System,
NoteCollection,
sub,
);
useEffect(() => {
if (!data) {
return;
}
if (!session) {
return;
}
for (const ev of data) {
if (ev?.kind === USER_EMOJIS) {
setUserEmojis(ev.tags);
}
if (ev?.kind === 10_000) {
// todo: decrypt ev.content tags
setMuted(session, ev.tags, ev.created_at);
}
if (ev?.kind === EventKind.ContactList) {
setFollows(session, ev.tags, ev.created_at);
}
if (ev?.kind === EventKind.Relays) {
setRelays(session, ev.tags, ev.created_at);
}
}
}, [session, data]);
const emojis = useUserEmojiPacks(pubkey, { tags: userEmojis });
useEffect(() => {
if (session) {
setEmojis(session, emojis);
}
}, [session, emojis]);
} }

View File

@ -6,13 +6,14 @@ import ReactDOM from "react-dom/client";
import { NostrSystem } from "@snort/system"; import { NostrSystem } from "@snort/system";
import { RouterProvider, createBrowserRouter } from "react-router-dom"; import { RouterProvider, createBrowserRouter } from "react-router-dom";
import { RootPage } from "./pages/root"; import { RootPage } from "pages/root";
import { LayoutPage } from "pages/layout"; import { LayoutPage } from "pages/layout";
import { ProfilePage } from "pages/profile-page"; import { ProfilePage } from "pages/profile-page";
import { StreamPage } from "pages/stream-page"; import { StreamPage } from "pages/stream-page";
import { ChatPopout } from "pages/chat-popout"; import { ChatPopout } from "pages/chat-popout";
import { LoginStore } from "login"; import { LoginStore } from "login";
import { StreamProvidersPage } from "pages/providers"; import { StreamProvidersPage } from "pages/providers";
import { defaultRelays } from "const";
export enum StreamState { export enum StreamState {
Live = "live", Live = "live",
@ -23,14 +24,10 @@ export enum StreamState {
export const System = new NostrSystem({}); export const System = new NostrSystem({});
export const Login = new LoginStore(); export const Login = new LoginStore();
export const Relays = [ Object.entries(defaultRelays).forEach((params) => {
"wss://relay.snort.social", const [relay, settings] = params;
"wss://nos.lol", System.ConnectToRelay(relay, settings);
"wss://relay.damus.io", });
"wss://nostr.wine",
];
Relays.forEach((r) => System.ConnectToRelay(r, { read: true, write: true }));
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@ -64,10 +61,10 @@ const router = createBrowserRouter([
}, },
]); ]);
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLDivElement document.getElementById("root") as HTMLDivElement,
); );
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<RouterProvider router={router} /> <RouterProvider router={router} />
</React.StrictMode> </React.StrictMode>,
); );

View File

@ -2,17 +2,27 @@ import { bytesToHex } from "@noble/curves/abstract/utils";
import { schnorr } from "@noble/curves/secp256k1"; import { schnorr } from "@noble/curves/secp256k1";
import { ExternalStore } from "@snort/shared"; import { ExternalStore } from "@snort/shared";
import { EventPublisher, Nip7Signer, PrivateKeySigner } from "@snort/system"; import { EventPublisher, Nip7Signer, PrivateKeySigner } from "@snort/system";
import type { EmojiPack, Relays } from "types";
import { defaultRelays } from "const";
export enum LoginType { export enum LoginType {
Nip7 = "nip7", Nip7 = "nip7",
PrivateKey = "private-key", PrivateKey = "private-key",
} }
interface ReplaceableTags {
tags: Array<string[]>;
timestamp: number;
}
export interface LoginSession { export interface LoginSession {
type: LoginType; type: LoginType;
pubkey: string; pubkey: string;
privateKey?: string; privateKey?: string;
follows: string[]; follows: ReplaceableTags;
muted: ReplaceableTags;
relays: Relays;
emojis: Array<EmojiPack>;
} }
export class LoginStore extends ExternalStore<LoginSession | undefined> { export class LoginStore extends ExternalStore<LoginSession | undefined> {
@ -33,7 +43,10 @@ export class LoginStore extends ExternalStore<LoginSession | undefined> {
this.#session = { this.#session = {
type, type,
pubkey: pk, pubkey: pk,
follows: [], muted: { tags: [], timestamp: 0 },
follows: { tags: [], timestamp: 0 },
relays: defaultRelays,
emojis: [],
}; };
this.#save(); this.#save();
} }
@ -43,7 +56,9 @@ export class LoginStore extends ExternalStore<LoginSession | undefined> {
type: LoginType.PrivateKey, type: LoginType.PrivateKey,
pubkey: bytesToHex(schnorr.getPublicKey(key)), pubkey: bytesToHex(schnorr.getPublicKey(key)),
privateKey: key, privateKey: key,
follows: [], follows: { tags: [], timestamp: 0 },
muted: { tags: [], timestamp: 0 },
emojis: [],
}; };
this.#save(); this.#save();
} }
@ -53,6 +68,11 @@ export class LoginStore extends ExternalStore<LoginSession | undefined> {
this.#save(); this.#save();
} }
updateSession(s: LoginSession) {
this.#session = s;
this.#save();
}
takeSnapshot() { takeSnapshot() {
return this.#session ? { ...this.#session } : undefined; return this.#session ? { ...this.#session } : undefined;
} }
@ -75,8 +95,52 @@ export function getPublisher(session: LoginSession) {
case LoginType.PrivateKey: { case LoginType.PrivateKey: {
return new EventPublisher( return new EventPublisher(
new PrivateKeySigner(session.privateKey!), new PrivateKeySigner(session.privateKey!),
session.pubkey session.pubkey,
); );
} }
} }
} }
export function setFollows(
state: LoginSession,
follows: Array<string>,
ts: number,
) {
if (state.follows.timestamp >= ts) {
return;
}
state.follows.tags = follows;
state.follows.timestamp = ts;
}
export function setEmojis(state: LoginSession, emojis: Array<EmojiPack>) {
state.emojis = emojis;
}
export function setMuted(
state: LoginSession,
muted: Array<string[]>,
ts: number,
) {
if (state.muted.timestamp >= ts) {
return;
}
state.muted.tags = muted;
state.muted.timestamp = ts;
}
export function setRelays(
state: LoginSession,
relays: Array<string>,
ts: number,
) {
if (state.relays.timestamp >= ts) {
return;
}
state.relays = relays.reduce((acc, r) => {
const [, relay] = r;
const write = r.length === 2 || r.includes("write");
const read = r.length === 2 || r.includes("read");
return { ...acc, [relay]: { read, write } };
}, {});
}

View File

@ -5,7 +5,7 @@ import { Outlet, useNavigate } from "react-router-dom";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { Icon } from "element/icon"; import { Icon } from "element/icon";
import { useLogin } from "hooks/login"; import { useLogin, useLoginEvents } from "hooks/login";
import { Profile } from "element/profile"; import { Profile } from "element/profile";
import { NewStreamDialog } from "element/new-stream"; import { NewStreamDialog } from "element/new-stream";
import { LoginSignup } from "element/login-signup"; import { LoginSignup } from "element/login-signup";
@ -17,6 +17,7 @@ export function LayoutPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const login = useLogin(); const login = useLogin();
const [showLogin, setShowLogin] = useState(false); const [showLogin, setShowLogin] = useState(false);
useLoginEvents(login?.pubkey, true);
function loggedIn() { function loggedIn() {
if (!login) return; if (!login) return;
@ -105,4 +106,4 @@ export function LayoutPage() {
<Outlet /> <Outlet />
</div> </div>
); );
} }

22
src/types.ts Normal file
View File

@ -0,0 +1,22 @@
export interface RelaySettings {
read: boolean;
write: boolean;
}
export interface Relays {
[key: string]: RelaySettings;
}
export type EmojiTag = ["emoji", string, string];
export interface Emoji {
native?: string;
id?: string;
}
export interface EmojiPack {
address: string;
name: string;
author: string;
emojis: EmojiTag[];
}