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/chat-message.tsx b/src/element/chat-message.tsx index 9c7801b..3ce52db 100644 --- a/src/element/chat-message.tsx +++ b/src/element/chat-message.tsx @@ -14,7 +14,7 @@ import { EmojiPicker } from "./emoji-picker"; import { Icon } from "./icon"; import { Emoji } from "./emoji"; import { Profile } from "./profile"; -import { Text } from "./text"; +import { Text } from "element/text"; import { SendZapsDialog } from "./send-zap"; import { findTag } from "../utils"; import type { EmojiPack } from "../hooks/emoji"; @@ -140,11 +140,19 @@ export function ChatMessage({ onClick={() => setShowZapDialog(true)} > } + icon={ + ev.pubkey === streamer && + // todo: styling is ready if we want to add stream badges + // TODO + } pubkey={ev.pubkey} profile={profile} /> - + {(hasReactions || hasZaps) && (
{hasZaps && ( diff --git a/src/element/emoji-pack.css b/src/element/emoji-pack.css index 463cfbd..40f3dd9 100644 --- a/src/element/emoji-pack.css +++ b/src/element/emoji-pack.css @@ -4,6 +4,10 @@ justify-content: space-between; } +.emoji-pack-title .name { + margin: 0; +} + .emoji-pack-title a { font-size: 14px; } @@ -30,3 +34,7 @@ .emoji-pack h4 { margin: 0; } + +.emoji-pack .btn { + font-size: 12px; +} diff --git a/src/element/emoji-pack.tsx b/src/element/emoji-pack.tsx index 59b5139..1e4cce2 100644 --- a/src/element/emoji-pack.tsx +++ b/src/element/emoji-pack.tsx @@ -1,17 +1,59 @@ import "./emoji-pack.css"; import { type NostrEvent } from "@snort/system"; +import { useLogin } from "hooks/login"; +import { toEmojiPack } from "hooks/emoji"; +import AsyncButton from "element/async-button"; import { Mention } from "element/mention"; import { findTag } from "utils"; +import { USER_EMOJIS } from "const"; +import { Login, System } from "index"; export function EmojiPack({ ev }: { ev: NostrEvent }) { + const login = useLogin(); const name = findTag(ev, "d"); + const isUsed = login.emojis.find( + (e) => e.author === ev.pubkey && e.name === name, + ); const emoji = ev.tags.filter((e) => e.at(0) === "emoji"); + + async function toggleEmojiPack() { + let newPacks = []; + if (isUsed) { + newPacks = login.emojis.filter( + (e) => e.pubkey !== ev.pubkey && e.name !== name, + ); + } else { + newPacks = [...login.emojis, toEmojiPack(ev)]; + } + const pub = login?.publisher(); + if (pub) { + const ev = await pub.generic((eb) => { + eb.kind(USER_EMOJIS).content(""); + for (const e of newPacks) { + eb.tag(["a", e.address]); + } + return eb; + }); + console.debug(ev); + System.BroadcastEvent(ev); + Login.setEmojis(newPacks, ev.created_at); + } + } + return (
-

{name}

- +
+

{name}

+ +
+ + {isUsed ? "Remove" : "Add"} +
{emoji.map((e) => { 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/event.css b/src/element/event.css index 33cfcd3..bfe09dd 100644 --- a/src/element/event.css +++ b/src/element/event.css @@ -1,12 +1,22 @@ .event-container .note { max-width: 320px; + display: flex; + flex-direction: column; } .event-container .goal { font-size: 14px; } -.event-container .goal .amount { +.event-container .goal .progress-root .amount { top: -8px; font-size: 10px; } + +.message .event-container .goal .progress-root .amount { + top: -6px; +} + +.message .event-container .note { + max-width: unset; +} diff --git a/src/element/external-link.tsx b/src/element/external-link.tsx index 5659b3a..179c471 100644 --- a/src/element/external-link.tsx +++ b/src/element/external-link.tsx @@ -1,7 +1,22 @@ +import { Icon } from "element/icon"; + +export function ExternalIconLink({ size = 32, href, ...rest }) { + return ( + + window.open(href, "_blank")} + {...rest} + /> + + ); +} + export function ExternalLink({ children, href }) { return ( {children} - ) + ); } diff --git a/src/element/follow-button.tsx b/src/element/follow-button.tsx index 260343f..42167f5 100644 --- a/src/element/follow-button.tsx +++ b/src/element/follow-button.tsx @@ -1,59 +1,52 @@ 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; -}) { +import { useLogin } from "hooks/login"; +import AsyncButton from "element/async-button"; +import { Login, System } from "index"; + +export function LoggedInFollowButton({ pubkey }: { pubkey: string }) { const login = useLogin(); - const following = useFollows(loggedIn, true); - const { tags, relays } = following ? following : { tags: [], relays: {} }; + const tags = login.follows.tags; const follows = tags.filter((t) => t.at(0) === "p"); const isFollowing = follows.find((t) => t.at(1) === pubkey); async function unfollow() { const pub = login?.publisher(); if (pub) { + const newFollows = tags.filter((t) => t.at(1) !== pubkey); const ev = await pub.generic((eb) => { - eb.kind(EventKind.ContactList).content(JSON.stringify(relays)); - for (const t of tags) { - const isFollow = t.at(0) === "p" && t.at(1) === pubkey; - if (!isFollow) { - eb.tag(t); - } + eb.kind(EventKind.ContactList).content(login.follows.content); + for (const t of newFollows) { + eb.tag(t); } return eb; }); console.debug(ev); System.BroadcastEvent(ev); + Login.setFollows(newFollows, login.follows.content, ev.created_at); } } async function follow() { const pub = login?.publisher(); if (pub) { + const newFollows = [...tags, ["p", pubkey]]; const ev = await pub.generic((eb) => { - eb.kind(EventKind.ContactList).content(JSON.stringify(relays)); - for (const tag of tags) { + eb.kind(EventKind.ContactList).content(login.follows.content); + for (const tag of newFollows) { eb.tag(tag); } - eb.tag(["p", pubkey]); return eb; }); console.debug(ev); System.BroadcastEvent(ev); + Login.setFollows(newFollows, login.follows.content, ev.created_at); } } return ( .messages { +.live-chat > .messages { display: flex; gap: 12px; flex-direction: column-reverse; @@ -37,12 +37,12 @@ } @media (min-width: 1020px) { - .live-chat>.messages { + .live-chat > .messages { flex-grow: 1; } } -.live-chat>.write-message { +.live-chat > .write-message { display: flex; gap: 8px; margin-top: auto; @@ -51,7 +51,7 @@ border-top: 1px solid var(--border, #171717); } -.live-chat>.write-message>div:nth-child(1) { +.live-chat > .write-message > div:nth-child(1) { height: 32px; flex-grow: 1; } @@ -77,15 +77,15 @@ } .live-chat .message .profile { - color: #34D2FE; + color: #34d2fe; } .live-chat .message.streamer .profile { - color: #F838D9; + color: #f838d9; } .live-chat .message a { - color: #F838D9; + color: #f838d9; } .live-chat .profile img { @@ -93,7 +93,7 @@ height: 24px; } -.live-chat .message>span { +.live-chat .message > span { font-weight: 400; font-size: 15px; line-height: 24px; @@ -172,13 +172,13 @@ position: relative; border-radius: 12px; border: 1px solid transparent; - background: #0A0A0A; + background: #0a0a0a; background-clip: padding-box; padding: 8px 12px; } .zap-container:before { - content: ''; + content: ""; position: absolute; top: 0; right: 0; @@ -186,20 +186,28 @@ left: 0; z-index: -1; margin: -1px; - background: linear-gradient(to bottom right, #FF902B, #F83838); + background: linear-gradient(to bottom right, #ff902b, #f83838); border-radius: inherit; } .zap-container .profile { - color: #FF8D2B; + color: #ff8d2b; } .zap-container .zap-amount { - color: #FF8D2B; + color: #ff8d2b; } .zap-container.big-zap:before { - background: linear-gradient(60deg, #2BD9FF, #8C8DED, #F838D9, #F83838, #FF902B, #DDF838); + background: linear-gradient( + 60deg, + #2bd9ff, + #8c8ded, + #f838d9, + #f83838, + #ff902b, + #ddf838 + ); animation: animatedgradient 3s ease alternate infinite; background-size: 300% 300%; } @@ -224,7 +232,7 @@ .zap-pill { border-radius: 100px; - background: rgba(255, 255, 255, 0.10); + background: rgba(255, 255, 255, 0.1); width: fit-content; display: flex; height: 24px; @@ -236,7 +244,7 @@ .zap-pill-icon { width: 12px; height: 12px; - color: #FF8D2B; + color: #ff8d2b; } .message-zap-container { @@ -252,7 +260,7 @@ margin-top: 4px; width: fit-content; z-index: 1; - transition: opacity .3s ease-out; + transition: opacity 0.3s ease-out; } @media (min-width: 1020px) { @@ -271,7 +279,7 @@ gap: 2px; border-radius: 100px; background: rgba(255, 255, 255, 0.05); - color: #FFFFFF66; + color: #ffffff66; } .message-zap-button:hover { @@ -299,7 +307,7 @@ align-items: center; gap: 2px; border-radius: 100px; - background: rgba(255, 255, 255, 0.10); + background: rgba(255, 255, 255, 0.1); } .message-reaction { @@ -311,7 +319,7 @@ .zap-pill-amount { text-transform: lowercase; - color: #FFF; + color: #fff; font-size: 12px; font-family: Outfit; font-style: normal; @@ -335,10 +343,17 @@ } .write-emoji-button { - color: #FFFFFF80; + color: #ffffff80; cursor: pointer; } .write-emoji-button:hover { color: white; } + +.message .profile .badge-icon { + background: transparent; + width: 18px; + height: 18px; + border-radius: unset; +} diff --git a/src/element/live-chat.tsx b/src/element/live-chat.tsx index a292015..193cc3e 100644 --- a/src/element/live-chat.tsx +++ b/src/element/live-chat.tsx @@ -26,7 +26,7 @@ import { ChatMessage } from "./chat-message"; import { Goal } from "./goal"; import { NewGoalDialog } from "./new-goal"; import { WriteMessage } from "./write-message"; -import { findTag, getHost } from "utils"; +import { findTag, getTagValues, getHost } from "utils"; export interface LiveChatOptions { canWrite?: boolean; @@ -79,10 +79,13 @@ export function LiveChat({ return () => System.ProfileLoader.UntrackMetadata(pubkeys); }, [feed.zaps]); - const userEmojiPacks = useEmoji(login?.pubkey); + const mutedPubkeys = useMemo(() => { + return new Set(getTagValues(login?.muted.tags ?? [], "p")); + }, [login]); + 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 +108,9 @@ export function LiveChat({ ); } }, [ev]); + const filteredEvents = useMemo(() => { + return events.filter((e) => !mutedPubkeys.has(e.pubkey)); + }, [events, mutedPubkeys]); return (
@@ -135,7 +141,7 @@ export function LiveChat({
)}
- {events.map((a) => { + {filteredEvents.map((a) => { switch (a.kind) { case LIVE_STREAM_CHAT: { return ( diff --git a/src/element/markdown.tsx b/src/element/markdown.tsx index 0632b97..c867686 100644 --- a/src/element/markdown.tsx +++ b/src/element/markdown.tsx @@ -1,200 +1,22 @@ import "./markdown.css"; -import { parseNostrLink } from "@snort/system"; -import type { ReactNode } from "react"; +import { createElement } from "react"; import { useMemo } from "react"; import ReactMarkdown from "react-markdown"; -import { Address } from "element/Address"; -import { Event } from "element/Event"; -import { Mention } from "element/mention"; -import { Emoji } from "element/emoji"; import { HyperText } from "element/hypertext"; - -const MentionRegex = /(#\[\d+\])/gi; -const NostrPrefixRegex = /^nostr:/; -const EmojiRegex = /:([\w-]+):/g; - -function extractEmoji(fragments: Fragment[], tags: string[][]) { - return fragments - .map((f) => { - if (typeof f === "string") { - return f.split(EmojiRegex).map((i) => { - const t = tags.find((a) => a[0] === "emoji" && a[1] === i); - if (t) { - return ; - } else { - return i; - } - }); - } - return f; - }) - .flat(); -} - -function extractMentions(fragments, tags) { - return fragments - .map((f) => { - if (typeof f === "string") { - return f.split(MentionRegex).map((match) => { - const matchTag = match.match(/#\[(\d+)\]/); - if (matchTag && matchTag.length === 2) { - const idx = parseInt(matchTag[1]); - const ref = tags?.find((a, i) => i === idx); - if (ref) { - switch (ref[0]) { - case "p": { - return ; - } - case "a": { - return
; - } - default: - // todo: e and t mentions - return ref[1]; - } - } - return null; - } else { - return match; - } - }); - } - return f; - }) - .flat(); -} - -function extractNprofiles(fragments) { - return fragments - .map((f) => { - if (typeof f === "string") { - return f.split(/(nostr:nprofile1[a-z0-9]+)/g).map((i) => { - if (i.startsWith("nostr:nprofile1")) { - try { - const link = parseNostrLink(i.replace(NostrPrefixRegex, "")); - return ; - } catch (error) { - return i; - } - } else { - return i; - } - }); - } - return f; - }) - .flat(); -} - -function extractNpubs(fragments) { - return fragments - .map((f) => { - if (typeof f === "string") { - return f.split(/(nostr:npub1[a-z0-9]+)/g).map((i) => { - if (i.startsWith("nostr:npub1")) { - try { - const link = parseNostrLink(i.replace(NostrPrefixRegex, "")); - return ; - } catch (error) { - return i; - } - } else { - return i; - } - }); - } - return f; - }) - .flat(); -} - -function extractNevents(fragments) { - return fragments - .map((f) => { - if (typeof f === "string") { - return f.split(/(nostr:nevent1[a-z0-9]+)/g).map((i) => { - if (i.startsWith("nostr:nevent1")) { - try { - const link = parseNostrLink(i.replace(NostrPrefixRegex, "")); - return ; - } catch (error) { - return i; - } - } else { - return i; - } - }); - } - return f; - }) - .flat(); -} - -function extractNaddrs(fragments) { - return fragments - .map((f) => { - if (typeof f === "string") { - return f.split(/(nostr:naddr1[a-z0-9]+)/g).map((i) => { - if (i.startsWith("nostr:naddr1")) { - try { - const link = parseNostrLink(i.replace(NostrPrefixRegex, "")); - return
; - } catch (error) { - console.error(error); - return i; - } - } else { - return i; - } - }); - } - return f; - }) - .flat(); -} - -function extractNoteIds(fragments) { - return fragments - .map((f) => { - if (typeof f === "string") { - return f.split(/(nostr:note1[a-z0-9]+)/g).map((i) => { - if (i.startsWith("nostr:note1")) { - try { - const link = parseNostrLink(i.replace(NostrPrefixRegex, "")); - return ; - } catch (error) { - return i; - } - } else { - return i; - } - }); - } - return f; - }) - .flat(); -} - -function transformText(ps, tags) { - let fragments = extractMentions(ps, tags); - fragments = extractNprofiles(fragments); - fragments = extractNevents(fragments); - fragments = extractNaddrs(fragments); - fragments = extractNoteIds(fragments); - fragments = extractNpubs(fragments); - fragments = extractEmoji(fragments, tags); - - return fragments; -} +import { transformText } from "element/text"; interface MarkdownProps { - children: ReactNode; + content: string; tags?: string[]; } -export function Markdown({ children, tags = [] }: MarkdownProps) { +export function Markdown({ + content, + tags = [], + element = "div", +}: MarkdownProps) { const components = useMemo(() => { return { li: ({ children, ...props }) => { @@ -202,15 +24,15 @@ export function Markdown({ children, tags = [] }: MarkdownProps) { }, td: ({ children }) => children && {transformText(children, tags)}, - p: ({ children }) => children &&

{transformText(children, tags)}

, + p: ({ children }) =>

{transformText(children, tags)}

, a: (props) => { return {props.children}; }, }; }, [tags]); - return ( -
- -
+ return createElement( + element, + { className: "markdown" }, + {content}, ); } diff --git a/src/element/mute-button.tsx b/src/element/mute-button.tsx new file mode 100644 index 0000000..4206b29 --- /dev/null +++ b/src/element/mute-button.tsx @@ -0,0 +1,63 @@ +import { useLogin } from "hooks/login"; +import AsyncButton from "element/async-button"; +import { Login, System } from "index"; +import { MUTED } from "const"; + +export function LoggedInMuteButton({ pubkey }: { pubkey: string }) { + const login = useLogin(); + const tags = login.muted.tags; + const muted = tags.filter((t) => t.at(0) === "p"); + const isMuted = muted.find((t) => t.at(1) === pubkey); + + async function unmute() { + const pub = login?.publisher(); + if (pub) { + const newMuted = tags.filter((t) => t.at(1) !== pubkey); + const ev = await pub.generic((eb) => { + eb.kind(MUTED).content(login.muted.content); + for (const t of newMuted) { + eb.tag(t); + } + return eb; + }); + console.debug(ev); + System.BroadcastEvent(ev); + Login.setMuted(newMuted, login.muted.content, ev.created_at); + } + } + + async function mute() { + const pub = login?.publisher(); + if (pub) { + const newMuted = [...tags, ["p", pubkey]]; + const ev = await pub.generic((eb) => { + eb.kind(MUTED).content(login.muted.content); + for (const tag of newMuted) { + eb.tag(tag); + } + return eb; + }); + console.debug(ev); + System.BroadcastEvent(ev); + Login.setMuted(newMuted, login.muted.content, ev.created_at); + } + } + + return ( + + {isMuted ? "Unmute" : "Mute"} + + ); +} + +export function MuteButton({ pubkey }: { pubkey: string }) { + const login = useLogin(); + return login?.pubkey ? ( + + ) : null; +} diff --git a/src/element/note.css b/src/element/note.css index 2fb260e..c2289f7 100644 --- a/src/element/note.css +++ b/src/element/note.css @@ -4,6 +4,11 @@ border-radius: 10px; } +.note .note-header { + display: flex; + justify-content: space-between; +} + .note .note-header .profile { font-size: 14px; } @@ -17,6 +22,11 @@ margin-left: 30px; } -.note .note-content .markdown > p { +.note .note-content .markdown > * { font-size: 14px; } + +.note .note-content .markdown > ul, +.note .note-content .markdown ol { + margin-left: 30px; +} diff --git a/src/element/note.tsx b/src/element/note.tsx index b79987d..ddf93fb 100644 --- a/src/element/note.tsx +++ b/src/element/note.tsx @@ -2,6 +2,7 @@ import "./note.css"; import { type NostrEvent } from "@snort/system"; import { Markdown } from "element/markdown"; +import { ExternalIconLink } from "element/external-link"; import { Profile } from "element/profile"; export function Note({ ev }: { ev: NostrEvent }) { @@ -9,9 +10,10 @@ export function Note({ ev }: { ev: NostrEvent }) {
+
- {ev.content} +
); diff --git a/src/element/send-zap.tsx b/src/element/send-zap.tsx index 07db3dc..5762cfb 100644 --- a/src/element/send-zap.tsx +++ b/src/element/send-zap.tsx @@ -9,10 +9,10 @@ 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"; +import { defaultRelays } from "const"; export interface LNURLLike { get name(): string; @@ -21,7 +21,7 @@ export interface LNURLLike { getInvoice( amountInSats: number, comment?: string, - zap?: NostrEvent + zap?: NostrEvent, ): Promise<{ pr?: string }>; } @@ -55,7 +55,7 @@ export function SendZaps({ const [comment, setComment] = useState(""); const [invoice, setInvoice] = useState(""); const login = useLogin(); - + const relays = Object.keys(defaultRelays); const name = targetName ?? svc?.name; async function loadService(lnurl: string) { const s = new LNURL(lnurl); @@ -78,7 +78,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 +90,7 @@ export function SendZaps({ zap = await pub.zap( amountInSats * 1000, pubkey, - Relays, + relays, undefined, comment, (eb) => { @@ -102,7 +104,7 @@ export function SendZaps({ eb.tag(["anon", ""]); } return eb; - } + }, ); } const invoice = await svc.getInvoice(amountInSats, comment, zap); diff --git a/src/element/stream-cards.tsx b/src/element/stream-cards.tsx index 9a53cd9..e7b8c83 100644 --- a/src/element/stream-cards.tsx +++ b/src/element/stream-cards.tsx @@ -9,10 +9,10 @@ import type { NostrEvent } from "@snort/system"; import { Toggle } from "element/toggle"; import { useLogin } from "hooks/login"; -import { useCards } from "hooks/cards"; +import { useCards, useUserCards } from "hooks/cards"; import { CARD, USER_CARDS } from "const"; import { toTag } from "utils"; -import { System } from "index"; +import { Login, System } from "index"; import { findTag } from "utils"; import { Icon } from "./icon"; import { ExternalLink } from "./external-link"; @@ -55,7 +55,7 @@ const CardPreview = forwardRef( ) : ( {title} ))} - +
); }, @@ -130,6 +130,7 @@ function Card({ canEdit, ev, cards }: CardProps) { }); console.debug(userCardsEv); System.BroadcastEvent(userCardsEv); + Login.setCards(newTags, userCardsEv.created_at); }, }), [canEdit, tags, identifier], @@ -278,18 +279,18 @@ function EditCard({ card, cards }: EditCardProps) { async function onCancel() { const pub = login?.publisher(); if (pub) { + const newTags = tags.filter((t) => !t.at(1).endsWith(`:${identifier}`)); const userCardsEv = await pub.generic((eb) => { eb.kind(USER_CARDS).content(""); - for (const tag of tags) { - if (!tag.at(1).endsWith(`:${identifier}`)) { - eb.tag(tag); - } + for (const tag of newTags) { + eb.tag(tag); } return eb; }); console.debug(userCardsEv); System.BroadcastEvent(userCardsEv); + Login.setCards(newTags, userCardsEv.created_at); setIsOpen(false); } } @@ -381,12 +382,11 @@ function AddCard({ cards }: AddCardProps) { ); } -export function StreamCards({ host }) { +export function StreamCardEditor() { const login = useLogin(); - const canEdit = login?.pubkey === host; - const cards = useCards(host, canEdit); + const cards = useUserCards(login.pubkey, login.cards.tags, true); const [isEditing, setIsEditing] = useState(false); - const components = ( + return ( <>
{cards.map((ev) => ( @@ -394,17 +394,35 @@ export function StreamCards({ host }) { ))} {isEditing && }
- {canEdit && ( -
- -
- )} +
+ +
); - return {components}; +} + +export function ReadOnlyStreamCards({ host }) { + const cards = useCards(host); + return ( +
+ {cards.map((ev) => ( + + ))} +
+ ); +} + +export function StreamCards({ host }) { + const login = useLogin(); + const canEdit = login?.pubkey === host; + return ( + + {canEdit ? : } + + ); } diff --git a/src/element/text.tsx b/src/element/text.tsx index 56070f3..e419156 100644 --- a/src/element/text.tsx +++ b/src/element/text.tsx @@ -1,32 +1,18 @@ import { useMemo, type ReactNode } from "react"; -import { validateNostrLink } from "@snort/system"; + +import { parseNostrLink, validateNostrLink } from "@snort/system"; + +import { Address } from "element/Address"; +import { Event } from "element/Event"; +import { Mention } from "element/mention"; +import { Emoji } from "element/emoji"; +import { HyperText } from "element/hypertext"; import { splitByUrl } from "utils"; -import { Emoji } from "./emoji"; -import { HyperText } from "./hypertext"; type Fragment = string | ReactNode; -function transformText(fragments: Fragment[], tags: string[][]) { - return extractLinks(extractEmoji(fragments, tags)); -} - -function extractEmoji(fragments: Fragment[], tags: string[][]) { - return fragments - .map((f) => { - if (typeof f === "string") { - return f.split(/:([\w-]+):/g).map((i) => { - const t = tags.find((a) => a[0] === "emoji" && a[1] === i); - if (t) { - return ; - } else { - return i; - } - }); - } - return f; - }) - .flat(); -} +const NostrPrefixRegex = /^nostr:/; +const EmojiRegex = /:([\w-]+):/g; function extractLinks(fragments: Fragment[]) { return fragments @@ -74,6 +60,147 @@ function extractLinks(fragments: Fragment[]) { .flat(); } +function extractEmoji(fragments: Fragment[], tags: string[][]) { + return fragments + .map((f) => { + if (typeof f === "string") { + return f.split(EmojiRegex).map((i) => { + const t = tags.find((a) => a[0] === "emoji" && a[1] === i); + if (t) { + return ; + } else { + return i; + } + }); + } + return f; + }) + .flat(); +} + +function extractNprofiles(fragments: Fragment[]) { + return fragments + .map((f) => { + if (typeof f === "string") { + return f.split(/(nostr:nprofile1[a-z0-9]+)/g).map((i) => { + if (i.startsWith("nostr:nprofile1")) { + try { + const link = parseNostrLink(i.replace(NostrPrefixRegex, "")); + return ; + } catch (error) { + return i; + } + } else { + return i; + } + }); + } + return f; + }) + .flat(); +} + +function extractNpubs(fragments: Fragment[]) { + return fragments + .map((f) => { + if (typeof f === "string") { + return f.split(/(nostr:npub1[a-z0-9]+)/g).map((i) => { + if (i.startsWith("nostr:npub1")) { + try { + const link = parseNostrLink(i.replace(NostrPrefixRegex, "")); + return ; + } catch (error) { + return i; + } + } else { + return i; + } + }); + } + return f; + }) + .flat(); +} + +function extractNevents(fragments: Fragment[]) { + return fragments + .map((f) => { + if (typeof f === "string") { + return f.split(/(nostr:nevent1[a-z0-9]+)/g).map((i) => { + if (i.startsWith("nostr:nevent1")) { + try { + const link = parseNostrLink(i.replace(NostrPrefixRegex, "")); + return ; + } catch (error) { + return i; + } + } else { + return i; + } + }); + } + return f; + }) + .flat(); +} + +function extractNaddrs(fragments: Fragment[]) { + return fragments + .map((f) => { + if (typeof f === "string") { + return f.split(/(nostr:naddr1[a-z0-9]+)/g).map((i) => { + if (i.startsWith("nostr:naddr1")) { + try { + const link = parseNostrLink(i.replace(NostrPrefixRegex, "")); + return
; + } catch (error) { + console.error(error); + return i; + } + } else { + return i; + } + }); + } + return f; + }) + .flat(); +} + +function extractNoteIds(fragments: Fragment[]) { + return fragments + .map((f) => { + if (typeof f === "string") { + return f.split(/(nostr:note1[a-z0-9]+)/g).map((i) => { + if (i.startsWith("nostr:note1")) { + try { + const link = parseNostrLink(i.replace(NostrPrefixRegex, "")); + return ; + } catch (error) { + return i; + } + } else { + return i; + } + }); + } + return f; + }) + .flat(); +} + +export function transformText(ps: Fragment[], tags: Array) { + let fragments = extractEmoji(ps, tags); + fragments = extractNprofiles(fragments); + fragments = extractNevents(fragments); + fragments = extractNaddrs(fragments); + fragments = extractNoteIds(fragments); + fragments = extractNpubs(fragments); + fragments = extractLinks(fragments); + + return fragments; +} + export function Text({ content, tags }: { content: string; tags: string[][] }) { // todo: RTL langugage support const element = useMemo(() => { diff --git a/src/hooks/cards.ts b/src/hooks/cards.ts index 0e7d849..ed6d095 100644 --- a/src/hooks/cards.ts +++ b/src/hooks/cards.ts @@ -11,6 +11,67 @@ import { USER_CARDS, CARD } from "const"; import { findTag } from "utils"; import { System } from "index"; +export function useUserCards( + pubkey: string, + userCards: Array, + leaveOpen = false, +) { + const related = useMemo(() => { + // filtering to only show CARD kinds for now, but in the future we could link and render anything + if (userCards?.length > 0) { + return userCards.filter( + (t) => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`), + ); + } + return []; + }, [userCards]); + + const subRelated = useMemo(() => { + if (!pubkey) return null; + const splitted = related.map((t) => t.at(1)!.split(":")); + const authors = splitted + .map((s) => s.at(1)) + .filter((s) => s) + .map((s) => s as string); + const identifiers = splitted + .map((s) => s.at(2)) + .filter((s) => s) + .map((s) => s as string); + + const rb = new RequestBuilder(`cards:${pubkey}`); + rb.withOptions({ leaveOpen }) + .withFilter() + .kinds([CARD]) + .authors(authors) + .tag("d", identifiers); + + return rb; + }, [pubkey, related]); + + const { data } = useRequestBuilder( + System, + NoteCollection, + subRelated, + ); + + const cards = useMemo(() => { + return related + .map((t) => { + const [k, pubkey, identifier] = t.at(1).split(":"); + const kind = Number(k); + return (data ?? []).find( + (e) => + e.kind === kind && + e.pubkey === pubkey && + findTag(e, "d") === identifier, + ); + }) + .filter((e) => e); + }, [related, data]); + + return cards; +} + export function useCards(pubkey: string, leaveOpen = false) { const sub = useMemo(() => { const b = new RequestBuilder(`user-cards:${pubkey.slice(0, 12)}`); diff --git a/src/hooks/emoji.tsx b/src/hooks/emoji.tsx index 54c28df..def0ac7 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,29 +9,15 @@ 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(/_$/, ""); } -function toEmojiPack(ev: NostrEvent): EmojiPack { +export function toEmojiPack(ev: NostrEvent): EmojiPack { const d = findTag(ev, "d") || ""; return { address: `${ev.kind}:${ev.pubkey}:${d}`, @@ -44,25 +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: Array) { const related = useMemo(() => { - if (userEmoji) { - return userEmoji.tags.filter( + if (userEmoji?.length > 0) { + return userEmoji.filter( (t) => t.at(0) === "a" && t.at(1)?.startsWith(`${EMOJI_PACK}:`), ); } @@ -101,8 +75,29 @@ export default function useEmoji(pubkey?: string) { }, [relatedData]); const emojis = useMemo(() => { - return uniqBy(emojiPacks.map(toEmojiPack), packId); + const packs = emojiPacks.map(toEmojiPack); + return uniqBy(packs, packId); }, [emojiPacks]); 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..7aaed86 100644 --- a/src/hooks/login.ts +++ b/src/hooks/login.ts @@ -1,17 +1,76 @@ -import { Login } from "index"; +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 { MUTED, USER_CARDS, USER_EMOJIS } from "const"; +import { System, Login } from "index"; import { getPublisher } from "login"; -import { useSyncExternalStore } from "react"; 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(), + ); + + 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, MUTED, USER_EMOJIS, USER_CARDS]); + return b; + }, [pubkey, leaveOpen]); + + const { data } = useRequestBuilder( + System, + NoteCollection, + sub, + ); + + useEffect(() => { + if (!data) { + return; + } + for (const ev of data) { + if (ev?.kind === USER_EMOJIS) { + setUserEmojis(ev.tags); + } + if (ev?.kind === USER_CARDS) { + Login.setCards(ev.tags, ev.created_at); + } + if (ev?.kind === MUTED) { + Login.setMuted(ev.tags, ev.content, ev.created_at); + } + if (ev?.kind === EventKind.ContactList) { + Login.setFollows(ev.tags, ev.content, ev.created_at); + } + } + }, [data]); + + const emojis = useUserEmojiPacks(pubkey, userEmojis); + useEffect(() => { + if (session) { + Login.setEmojis(emojis); + } + }, [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..1b20352 100644 --- a/src/login.ts +++ b/src/login.ts @@ -2,27 +2,46 @@ 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 } from "types"; export enum LoginType { Nip7 = "nip7", PrivateKey = "private-key", } +interface ReplaceableTags { + tags: Array; + content?: string; + timestamp: number; +} + export interface LoginSession { type: LoginType; pubkey: string; privateKey?: string; - follows: string[]; + follows: ReplaceableTags; + muted: ReplaceableTags; + cards: ReplaceableTags; + emojis: Array; } +const initialState = { + follows: { tags: [], timestamp: 0 }, + muted: { tags: [], timestamp: 0 }, + cards: { tags: [], timestamp: 0 }, + emojis: [], +}; + +const SESSION_KEY = "session"; + export class LoginStore extends ExternalStore { #session?: LoginSession; constructor() { super(); - const json = window.localStorage.getItem("session"); + const json = window.localStorage.getItem(SESSION_KEY); if (json) { - this.#session = JSON.parse(json); + this.#session = { ...initialState, ...JSON.parse(json) }; if (this.#session) { this.#session.type ??= LoginType.Nip7; } @@ -33,7 +52,7 @@ export class LoginStore extends ExternalStore { this.#session = { type, pubkey: pk, - follows: [], + ...initialState, }; this.#save(); } @@ -43,7 +62,7 @@ export class LoginStore extends ExternalStore { type: LoginType.PrivateKey, pubkey: bytesToHex(schnorr.getPublicKey(key)), privateKey: key, - follows: [], + ...initialState, }; this.#save(); } @@ -57,11 +76,45 @@ export class LoginStore extends ExternalStore { return this.#session ? { ...this.#session } : undefined; } + setFollows(follows: Array, content: string, ts: number) { + if (this.#session.follows.timestamp >= ts) { + return; + } + this.#session.follows.tags = follows; + this.#session.follows.content = content; + this.#session.follows.timestamp = ts; + this.#save(); + } + + setEmojis(emojis: Array) { + this.#session.emojis = emojis; + this.#save(); + } + + setMuted(muted: Array, content: string, ts: number) { + if (this.#session.muted.timestamp >= ts) { + return; + } + this.#session.muted.tags = muted; + this.#session.muted.content = content; + this.#session.muted.timestamp = ts; + this.#save(); + } + + setCards(cards: Array, ts: number) { + if (this.#session.cards.timestamp >= ts) { + return; + } + this.#session.cards.tags = cards; + this.#session.cards.timestamp = ts; + this.#save(); + } + #save() { if (this.#session) { - window.localStorage.setItem("session", JSON.stringify(this.#session)); + window.localStorage.setItem(SESSION_KEY, JSON.stringify(this.#session)); } else { - window.localStorage.removeItem("session"); + window.localStorage.removeItem(SESSION_KEY); } this.notifyChange(); } @@ -75,7 +128,7 @@ export function getPublisher(session: LoginSession) { case LoginType.PrivateKey: { return new EventPublisher( new PrivateKeySigner(session.privateKey!), - session.pubkey + session.pubkey, ); } } 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/pages/profile-page.tsx b/src/pages/profile-page.tsx index 34dab78..75864fd 100644 --- a/src/pages/profile-page.tsx +++ b/src/pages/profile-page.tsx @@ -15,6 +15,7 @@ import { Icon } from "element/icon"; import { SendZapsDialog } from "element/send-zap"; import { VideoTile } from "element/video-tile"; import { FollowButton } from "element/follow-button"; +import { MuteButton } from "element/mute-button"; import { useProfile } from "hooks/profile"; import useTopZappers from "hooks/top-zappers"; import { Text } from "element/text"; @@ -131,6 +132,7 @@ export function ProfilePage() { /> )} +
{profile?.name &&

{profile.name}

} 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[]; +} diff --git a/src/utils.ts b/src/utils.ts index ce7ef68..6d915b5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -90,3 +90,7 @@ export async function openFile(): Promise { elm.click(); }); } + +export function getTagValues(tags: Array, tag: string) { + return tags.filter((t) => t.at(0) === tag).map((t) => t.at(1)); +}