diff --git a/src/const.ts b/src/const.ts index 7930080..7ddc519 100644 --- a/src/const.ts +++ b/src/const.ts @@ -7,3 +7,11 @@ export const USER_EMOJIS = 10_030 as EventKind; export const GOAL = 9041 as EventKind; export const USER_CARDS = 17_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 }, +}; diff --git a/src/element/emoji.tsx b/src/element/emoji.tsx index 1c04b25..0362670 100644 --- a/src/element/emoji.tsx +++ b/src/element/emoji.tsx @@ -1,5 +1,6 @@ import "./emoji.css"; import { useMemo } from "react"; +import { EmojiTag } from "types"; export type EmojiProps = { name: string; @@ -10,8 +11,6 @@ export function Emoji({ name, url }: EmojiProps) { return {name}; } -export type EmojiTag = ["emoji", string, string]; - export function Emojify({ content, emoji, diff --git a/src/element/follow-button.tsx b/src/element/follow-button.tsx index 260343f..8361569 100644 --- a/src/element/follow-button.tsx +++ b/src/element/follow-button.tsx @@ -1,19 +1,16 @@ import { EventKind } from "@snort/system"; import { useLogin } from "hooks/login"; -import useFollows from "hooks/follows"; import AsyncButton from "element/async-button"; import { System } from "index"; export function LoggedInFollowButton({ - loggedIn, pubkey, }: { - loggedIn: string; pubkey: string; }) { const login = useLogin(); - const following = useFollows(loggedIn, true); - const { tags, relays } = following ? following : { tags: [], relays: {} }; + const tags = login?.follows.tags ?? [] + const relays = login?.relays const follows = tags.filter((t) => t.at(0) === "p"); const isFollowing = follows.find((t) => t.at(1) === pubkey); @@ -53,7 +50,7 @@ export function LoggedInFollowButton({ return ( System.ProfileLoader.UntrackMetadata(pubkeys); }, [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 allEmojiPacks = useMemo(() => { - return uniqBy(channelEmojiPacks.concat(userEmojiPacks), packId); + return uniqBy(userEmojiPacks.concat(channelEmojiPacks), packId); }, [userEmojiPacks, channelEmojiPacks]); const zaps = feed.zaps @@ -105,6 +110,9 @@ export function LiveChat({ ); } }, [ev]); + const filteredEvents = useMemo(() => { + return events.filter((e) => !mutedPubkeys.has(e.pubkey)); + }, [events, mutedPubkeys]); return (
@@ -135,7 +143,7 @@ export function LiveChat({
)}
- {events.map((a) => { + {filteredEvents.map((a) => { switch (a.kind) { case LIVE_STREAM_CHAT: { return ( diff --git a/src/element/send-zap.tsx b/src/element/send-zap.tsx index 07db3dc..021355c 100644 --- a/src/element/send-zap.tsx +++ b/src/element/send-zap.tsx @@ -9,7 +9,6 @@ import { bytesToHex } from "@noble/curves/abstract/utils"; import { formatSats } from "../number"; import { Icon } from "./icon"; import AsyncButton from "./async-button"; -import { Relays } from "index"; import QrCode from "./qr-code"; import { useLogin } from "hooks/login"; import Copy from "./copy"; @@ -21,7 +20,7 @@ export interface LNURLLike { getInvoice( amountInSats: number, comment?: string, - zap?: NostrEvent + zap?: NostrEvent, ): Promise<{ pr?: string }>; } @@ -55,7 +54,7 @@ export function SendZaps({ const [comment, setComment] = useState(""); const [invoice, setInvoice] = useState(""); const login = useLogin(); - + const relays = Object.keys(login.relays); const name = targetName ?? svc?.name; async function loadService(lnurl: string) { const s = new LNURL(lnurl); @@ -78,7 +77,9 @@ export function SendZaps({ let pub = login?.publisher(); let isAnon = false; if (!pub) { - pub = EventPublisher.privateKey(bytesToHex(secp256k1.utils.randomPrivateKey())); + pub = EventPublisher.privateKey( + bytesToHex(secp256k1.utils.randomPrivateKey()), + ); isAnon = true; } @@ -88,7 +89,7 @@ export function SendZaps({ zap = await pub.zap( amountInSats * 1000, pubkey, - Relays, + relays, undefined, comment, (eb) => { @@ -102,7 +103,7 @@ export function SendZaps({ eb.tag(["anon", ""]); } return eb; - } + }, ); } const invoice = await svc.getInvoice(amountInSats, comment, zap); diff --git a/src/hooks/emoji.tsx b/src/hooks/emoji.tsx index 54c28df..3675f42 100644 --- a/src/hooks/emoji.tsx +++ b/src/hooks/emoji.tsx @@ -1,3 +1,6 @@ +import { useMemo } from "react"; +import uniqBy from "lodash.uniqby"; + import { RequestBuilder, ReplaceableNoteStore, @@ -6,23 +9,9 @@ import { } from "@snort/system"; import { useRequestBuilder } from "@snort/system-react"; import { System } from "index"; -import { useMemo } from "react"; import { findTag } from "utils"; import { EMOJI_PACK, USER_EMOJIS } from "const"; -import type { EmojiTag } from "../element/emoji"; -import uniqBy from "lodash.uniqby"; - -export interface Emoji { - native?: string; - id?: string; -} - -export interface EmojiPack { - address: string; - name: string; - author: string; - emojis: EmojiTag[]; -} +import { EmojiPack } from "types"; function cleanShortcode(shortcode?: string) { return shortcode?.replace(/\s+/g, "_").replace(/_$/, ""); @@ -44,22 +33,10 @@ export function packId(pack: EmojiPack): string { return `${pack.author}:${pack.name}`; } -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( - System, - ReplaceableNoteStore, - sub, - ); - +export function useUserEmojiPacks( + pubkey?: string, + userEmoji: { tags: string[][] }, +) { const related = useMemo(() => { if (userEmoji) { return userEmoji.tags.filter( @@ -106,3 +83,23 @@ export default function useEmoji(pubkey?: string) { 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( + System, + ReplaceableNoteStore, + sub, + ); + + const emojis = useUserEmojiPacks(pubkey, userEmoji ?? { tags: [] }); + return emojis; +} diff --git a/src/hooks/follows.ts b/src/hooks/follows.ts deleted file mode 100644 index a9f3921..0000000 --- a/src/hooks/follows.ts +++ /dev/null @@ -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( - System, - ReplaceableNoteStore, - sub, - ); - - const relays = JSON.parse(data?.content.length > 0 ? data?.content : "{}"); - return data ? { tags: data.tags, relays } : null; -} diff --git a/src/hooks/login.ts b/src/hooks/login.ts index d7662e6..84ad78c 100644 --- a/src/hooks/login.ts +++ b/src/hooks/login.ts @@ -1,17 +1,100 @@ -import { Login } from "index"; -import { getPublisher } from "login"; -import { useSyncExternalStore } from "react"; +import { useSyncExternalStore, useMemo, useState, useEffect } 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() { const session = useSyncExternalStore( (c) => Login.hook(c), - () => Login.snapshot() + () => Login.snapshot(), ); if (!session) return; return { ...session, publisher: () => { 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( + 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]); } diff --git a/src/index.tsx b/src/index.tsx index 378861c..09161db 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,13 +6,14 @@ import ReactDOM from "react-dom/client"; import { NostrSystem } from "@snort/system"; import { RouterProvider, createBrowserRouter } from "react-router-dom"; -import { RootPage } from "./pages/root"; +import { RootPage } from "pages/root"; import { LayoutPage } from "pages/layout"; import { ProfilePage } from "pages/profile-page"; import { StreamPage } from "pages/stream-page"; import { ChatPopout } from "pages/chat-popout"; import { LoginStore } from "login"; import { StreamProvidersPage } from "pages/providers"; +import { defaultRelays } from "const"; export enum StreamState { Live = "live", @@ -23,14 +24,10 @@ export enum StreamState { export const System = new NostrSystem({}); export const Login = new LoginStore(); -export const Relays = [ - "wss://relay.snort.social", - "wss://nos.lol", - "wss://relay.damus.io", - "wss://nostr.wine", -]; - -Relays.forEach((r) => System.ConnectToRelay(r, { read: true, write: true })); +Object.entries(defaultRelays).forEach((params) => { + const [relay, settings] = params; + System.ConnectToRelay(relay, settings); +}); const router = createBrowserRouter([ { @@ -64,10 +61,10 @@ const router = createBrowserRouter([ }, ]); const root = ReactDOM.createRoot( - document.getElementById("root") as HTMLDivElement + document.getElementById("root") as HTMLDivElement, ); root.render( - + , ); diff --git a/src/login.ts b/src/login.ts index 1a46871..3089f5a 100644 --- a/src/login.ts +++ b/src/login.ts @@ -2,17 +2,27 @@ import { bytesToHex } from "@noble/curves/abstract/utils"; import { schnorr } from "@noble/curves/secp256k1"; import { ExternalStore } from "@snort/shared"; import { EventPublisher, Nip7Signer, PrivateKeySigner } from "@snort/system"; +import type { EmojiPack, Relays } from "types"; +import { defaultRelays } from "const"; export enum LoginType { Nip7 = "nip7", PrivateKey = "private-key", } +interface ReplaceableTags { + tags: Array; + timestamp: number; +} + export interface LoginSession { type: LoginType; pubkey: string; privateKey?: string; - follows: string[]; + follows: ReplaceableTags; + muted: ReplaceableTags; + relays: Relays; + emojis: Array; } export class LoginStore extends ExternalStore { @@ -33,7 +43,10 @@ export class LoginStore extends ExternalStore { this.#session = { type, pubkey: pk, - follows: [], + muted: { tags: [], timestamp: 0 }, + follows: { tags: [], timestamp: 0 }, + relays: defaultRelays, + emojis: [], }; this.#save(); } @@ -43,7 +56,9 @@ export class LoginStore extends ExternalStore { type: LoginType.PrivateKey, pubkey: bytesToHex(schnorr.getPublicKey(key)), privateKey: key, - follows: [], + follows: { tags: [], timestamp: 0 }, + muted: { tags: [], timestamp: 0 }, + emojis: [], }; this.#save(); } @@ -53,6 +68,11 @@ export class LoginStore extends ExternalStore { this.#save(); } + updateSession(s: LoginSession) { + this.#session = s; + this.#save(); + } + takeSnapshot() { return this.#session ? { ...this.#session } : undefined; } @@ -75,8 +95,52 @@ export function getPublisher(session: LoginSession) { case LoginType.PrivateKey: { return new EventPublisher( new PrivateKeySigner(session.privateKey!), - session.pubkey + session.pubkey, ); } } } + +export function setFollows( + state: LoginSession, + follows: Array, + ts: number, +) { + if (state.follows.timestamp >= ts) { + return; + } + state.follows.tags = follows; + state.follows.timestamp = ts; +} + +export function setEmojis(state: LoginSession, emojis: Array) { + state.emojis = emojis; +} + +export function setMuted( + state: LoginSession, + muted: Array, + ts: number, +) { + if (state.muted.timestamp >= ts) { + return; + } + state.muted.tags = muted; + state.muted.timestamp = ts; +} + +export function setRelays( + state: LoginSession, + relays: Array, + 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 } }; + }, {}); +} diff --git a/src/pages/layout.tsx b/src/pages/layout.tsx index fee8b89..02e9531 100644 --- a/src/pages/layout.tsx +++ b/src/pages/layout.tsx @@ -5,7 +5,7 @@ import { Outlet, useNavigate } from "react-router-dom"; import { Helmet } from "react-helmet"; import { Icon } from "element/icon"; -import { useLogin } from "hooks/login"; +import { useLogin, useLoginEvents } from "hooks/login"; import { Profile } from "element/profile"; import { NewStreamDialog } from "element/new-stream"; import { LoginSignup } from "element/login-signup"; @@ -17,6 +17,7 @@ export function LayoutPage() { const navigate = useNavigate(); const login = useLogin(); const [showLogin, setShowLogin] = useState(false); + useLoginEvents(login?.pubkey, true); function loggedIn() { if (!login) return; @@ -105,4 +106,4 @@ export function LayoutPage() {
); -} \ No newline at end of file +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..d60050d --- /dev/null +++ b/src/types.ts @@ -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[]; +}