diff --git a/package.json b/package.json index 33b6026..e2e6237 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "react-dom": "^18.2.0", "react-helmet": "^6.1.0", "react-intersection-observer": "^9.5.1", + "react-markdown": "^8.0.7", "react-router-dom": "^6.13.0", "react-tag-input-component": "^2.0.2", "semantic-sdp": "^3.26.2", diff --git a/public/icons.svg b/public/icons.svg index 1571504..965d0ec 100644 --- a/public/icons.svg +++ b/public/icons.svg @@ -54,5 +54,8 @@ + + + diff --git a/src/const.ts b/src/const.ts index 124f1c0..7930080 100644 --- a/src/const.ts +++ b/src/const.ts @@ -2,4 +2,8 @@ import { EventKind } from "@snort/system"; export const LIVE_STREAM = 30_311 as EventKind; export const LIVE_STREAM_CHAT = 1_311 as EventKind; +export const EMOJI_PACK = 30_030 as EventKind; +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; diff --git a/src/element/Address.tsx b/src/element/Address.tsx new file mode 100644 index 0000000..05bcb6c --- /dev/null +++ b/src/element/Address.tsx @@ -0,0 +1,19 @@ +import { type NostrLink } from "@snort/system"; + +import { useEvent } from "hooks/event"; +import { EMOJI_PACK } from "const"; +import { EmojiPack } from "element/emoji-pack"; + +interface AddressProps { + link: NostrLink; +} + +export function Address({ link }: AddressProps) { + const event = useEvent(link); + + if (event?.kind === EMOJI_PACK) { + return ; + } + + return null; +} diff --git a/src/element/Event.tsx b/src/element/Event.tsx new file mode 100644 index 0000000..bb43c08 --- /dev/null +++ b/src/element/Event.tsx @@ -0,0 +1,33 @@ +import "./event.css"; + +import { type NostrLink, EventKind } from "@snort/system"; +import { useEvent } from "hooks/event"; +import { GOAL } from "const"; +import { Goal } from "element/goal"; +import { Note } from "element/note"; + +interface EventProps { + link: NostrLink; +} + +export function Event({ link }: EventProps) { + const event = useEvent(link); + + if (event && event.kind === GOAL) { + return ( +
+ +
+ ); + } + + if (event && event.kind === EventKind.TextNote) { + return ( +
+ +
+ ); + } + + return {link.id}; +} diff --git a/src/element/address.css b/src/element/address.css new file mode 100644 index 0000000..e69de29 diff --git a/src/element/chat-message.tsx b/src/element/chat-message.tsx index c0fdc49..9c7801b 100644 --- a/src/element/chat-message.tsx +++ b/src/element/chat-message.tsx @@ -58,7 +58,7 @@ export function ChatMessage({ const login = useLogin(); const profile = useUserProfile( System, - inView?.isIntersecting ? ev.pubkey : undefined + inView?.isIntersecting ? ev.pubkey : undefined, ); const zapTarget = profile?.lud16 ?? profile?.lud06; const zaps = useMemo(() => { @@ -178,16 +178,16 @@ export function ChatMessage({ style={ isTablet ? { - display: showZapDialog || isHovering ? "flex" : "none", - } + display: showZapDialog || isHovering ? "flex" : "none", + } : { - position: "fixed", - top: topOffset ? topOffset - 12 : 0, - left: leftOffset ? leftOffset - 32 : 0, - opacity: showZapDialog || isHovering ? 1 : 0, - pointerEvents: - showZapDialog || isHovering ? "auto" : "none", - } + position: "fixed", + top: topOffset ? topOffset - 12 : 0, + left: leftOffset ? leftOffset - 32 : 0, + opacity: showZapDialog || isHovering ? 1 : 0, + pointerEvents: + showZapDialog || isHovering ? "auto" : "none", + } } > {zapTarget && ( diff --git a/src/element/emoji-pack.css b/src/element/emoji-pack.css new file mode 100644 index 0000000..463cfbd --- /dev/null +++ b/src/element/emoji-pack.css @@ -0,0 +1,32 @@ +.emoji-pack-title { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.emoji-pack-title a { + font-size: 14px; +} + +.emoji-pack-emojis { + margin-top: 12px; + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 4px; +} + +.emoji-definition { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; +} + +.emoji-name { + font-size: 10px; +} + +.emoji-pack h4 { + margin: 0; +} diff --git a/src/element/emoji-pack.tsx b/src/element/emoji-pack.tsx new file mode 100644 index 0000000..59b5139 --- /dev/null +++ b/src/element/emoji-pack.tsx @@ -0,0 +1,29 @@ +import "./emoji-pack.css"; +import { type NostrEvent } from "@snort/system"; + +import { Mention } from "element/mention"; +import { findTag } from "utils"; + +export function EmojiPack({ ev }: { ev: NostrEvent }) { + const name = findTag(ev, "d"); + const emoji = ev.tags.filter((e) => e.at(0) === "emoji"); + return ( +
+
+

{name}

+ +
+
+ {emoji.map((e) => { + const [, name, image] = e; + return ( +
+ {name} + {name} +
+ ); + })} +
+
+ ); +} diff --git a/src/element/event.css b/src/element/event.css new file mode 100644 index 0000000..42f3b97 --- /dev/null +++ b/src/element/event.css @@ -0,0 +1,8 @@ +.event-container .goal { + font-size: 14px; +} + +.event-container .goal .amount { + top: -8px; + font-size: 10px; +} diff --git a/src/element/external-link.tsx b/src/element/external-link.tsx new file mode 100644 index 0000000..5659b3a --- /dev/null +++ b/src/element/external-link.tsx @@ -0,0 +1,7 @@ +export function ExternalLink({ children, href }) { + return ( + + {children} + + ) +} diff --git a/src/element/file-uploader.css b/src/element/file-uploader.css new file mode 100644 index 0000000..bfbddfd --- /dev/null +++ b/src/element/file-uploader.css @@ -0,0 +1,27 @@ +.file-uploader-container { + display: flex; + justify-content: space-between; +} + +.file-uploader input[type="file"] { + display: none; +} + +.file-uploader { + align-self: flex-start; + background: white; + color: black; + max-width: 100px; + border-radius: 10px; + padding: 6px 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.image-preview { + width: 82px; + height: 60px; + border-radius: 10px; +} diff --git a/src/element/file-uploader.tsx b/src/element/file-uploader.tsx new file mode 100644 index 0000000..a43006a --- /dev/null +++ b/src/element/file-uploader.tsx @@ -0,0 +1,75 @@ +import "./file-uploader.css"; +import { VoidApi } from "@void-cat/api"; +import { useState } from "react"; + +const voidCatHost = "https://void.cat"; +const fileExtensionRegex = /\.([\w]{1,7})$/i; +const voidCatApi = new VoidApi(voidCatHost); + +type UploadResult = { + url?: string; + error?: string; +}; + +async function voidCatUpload(file: File | Blob): Promise { + const uploader = voidCatApi.getUploader(file); + + const rsp = await uploader.upload({ + "V-Strip-Metadata": "true", + }); + if (rsp.ok) { + let ext = file.name.match(fileExtensionRegex); + if (rsp.file?.metadata?.mimeType === "image/webp") { + ext = ["", "webp"]; + } + const resultUrl = + rsp.file?.metadata?.url ?? + `${voidCatHost}/d/${rsp.file?.id}${ext ? `.${ext[1]}` : ""}`; + + const ret = { + url: resultUrl, + } as UploadResult; + + return ret; + } else { + return { + error: rsp.errorMessage, + }; + } +} + +export function FileUploader({ onFileUpload }) { + const [img, setImg] = useState(); + const [isUploading, setIsUploading] = useState(false); + + async function onFileChange(ev) { + const file = ev.target.files[0]; + if (file) { + try { + setIsUploading(true); + const upload = await voidCatUpload(file); + if (upload.url) { + setImg(upload.url); + onFileUpload(upload.url); + } + if (upload.error) { + console.error(upload.error); + } + } catch (error) { + console.error(error); + } finally { + setIsUploading(false); + } + } + } + + return ( +
+ + {img && } +
+ ); +} diff --git a/src/element/follow-button.tsx b/src/element/follow-button.tsx index bf6bee4..260343f 100644 --- a/src/element/follow-button.tsx +++ b/src/element/follow-button.tsx @@ -13,8 +13,8 @@ export function LoggedInFollowButton({ }) { const login = useLogin(); const following = useFollows(loggedIn, true); - const { tags, relays } = following ? following : { tags: [], relays: {} } - const follows = tags.filter((t) => t.at(0) === "p") + const { tags, relays } = following ? following : { tags: [], relays: {} }; + const follows = tags.filter((t) => t.at(0) === "p"); const isFollowing = follows.find((t) => t.at(1) === pubkey); async function unfollow() { @@ -23,7 +23,7 @@ export function LoggedInFollowButton({ 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 + const isFollow = t.at(0) === "p" && t.at(1) === pubkey; if (!isFollow) { eb.tag(t); } diff --git a/src/element/goal.tsx b/src/element/goal.tsx index 1f816fa..d7f838c 100644 --- a/src/element/goal.tsx +++ b/src/element/goal.tsx @@ -2,19 +2,23 @@ import "./goal.css"; import { useMemo } from "react"; import * as Progress from "@radix-ui/react-progress"; import Confetti from "react-confetti"; -import { ParsedZap, NostrEvent } from "@snort/system"; -import { Icon } from "./icon"; + +import { type NostrEvent } from "@snort/system"; +import { useUserProfile } from "@snort/system-react"; + import { findTag } from "utils"; import { formatSats } from "number"; import usePreviousValue from "hooks/usePreviousValue"; +import { SendZapsDialog } from "element/send-zap"; +import { useZaps } from "hooks/goals"; +import { getName } from "element/profile"; +import { System } from "index"; +import { Icon } from "./icon"; -export function Goal({ - ev, - zaps, -}: { - ev: NostrEvent; - zaps: ParsedZap[]; -}) { +export function Goal({ ev }: { ev: NostrEvent }) { + const profile = useUserProfile(System, ev.pubkey); + const zapTarget = profile?.lud16 ?? profile?.lud06; + const zaps = useZaps(ev, true); const goalAmount = useMemo(() => { const amount = findTag(ev, "amount"); return amount ? Number(amount) / 1000 : null; @@ -34,8 +38,8 @@ export function Goal({ const isFinished = progress >= 100; const previousValue = usePreviousValue(isFinished); - return ( -
+ const goalContent = ( +
{ev.content.length > 0 &&

{ev.content}

}
@@ -61,4 +65,16 @@ export function Goal({ )}
); + + return zapTarget ? ( + + ) : ( + goalContent + ); } diff --git a/src/element/hypertext.tsx b/src/element/hypertext.tsx index f7b5e46..8f378b8 100644 --- a/src/element/hypertext.tsx +++ b/src/element/hypertext.tsx @@ -1,25 +1,63 @@ import { NostrLink } from "./nostr-link"; +const FileExtensionRegex = /\.([\w]+)$/i; + interface HyperTextProps { link: string; } -export function HyperText({ link }: HyperTextProps) { +export function HyperText({ link, children }: HyperTextProps) { try { const url = new URL(link); - if (url.protocol === "nostr:" || url.protocol === "web+nostr:") { + const extension = + FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1; + + if (extension) { + switch (extension) { + case "gif": + case "jpg": + case "jpeg": + case "png": + case "bmp": + case "webp": { + return ( + {url.toString()} + ); + } + case "wav": + case "mp3": + case "ogg": { + return
)} @@ -135,7 +130,7 @@ export function LiveChat({
- {goal && } + {goal && } {login?.pubkey === streamer && }
)} @@ -155,7 +150,7 @@ export function LiveChat({ } case EventKind.ZapReceipt: { const zap = zaps.find( - (b) => b.id === a.id && b.receiver === streamer + (b) => b.id === a.id && b.receiver === streamer, ); if (zap) { return ; @@ -179,7 +174,7 @@ export function LiveChat({ ); } -const BIG_ZAP_THRESHOLD = 100_000; +const BIG_ZAP_THRESHOLD = 50_000; function ChatZap({ zap }: { zap: ParsedZap }) { if (!zap.valid) { @@ -202,7 +197,7 @@ function ChatZap({ zap }: { zap: ParsedZap }) { {formatSats(zap.amount)} sats - {zap.content && ( + {zap.content && (
diff --git a/src/element/markdown.css b/src/element/markdown.css new file mode 100644 index 0000000..5609eb4 --- /dev/null +++ b/src/element/markdown.css @@ -0,0 +1,24 @@ +.markdown a { + color: var(--text-link); +} + +.markdown > ul, .markdown > ol { + margin: 0; + padding: 0 12px; + font-size: 18px; + font-weight: 400; + line-height: 29px; +} + +.markdown > p { + font-size: 18px; + font-style: normal; + overflow-wrap: break-word; + font-weight: 400; + line-height: 29px; /* 161.111% */ +} + +.markdown > img { + max-height: 230px; + width: 100%; +} diff --git a/src/element/markdown.tsx b/src/element/markdown.tsx new file mode 100644 index 0000000..0632b97 --- /dev/null +++ b/src/element/markdown.tsx @@ -0,0 +1,216 @@ +import "./markdown.css"; + +import { parseNostrLink } from "@snort/system"; +import type { ReactNode } 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; +} + +interface MarkdownProps { + children: ReactNode; + tags?: string[]; +} + +export function Markdown({ children, tags = [] }: MarkdownProps) { + const components = useMemo(() => { + return { + li: ({ children, ...props }) => { + return children &&
  • {transformText(children, tags)}
  • ; + }, + td: ({ children }) => + children && {transformText(children, tags)}, + p: ({ children }) => children &&

    {transformText(children, tags)}

    , + a: (props) => { + return {props.children}; + }, + }; + }, [tags]); + return ( +
    + +
    + ); +} diff --git a/src/element/note.css b/src/element/note.css new file mode 100644 index 0000000..2fb260e --- /dev/null +++ b/src/element/note.css @@ -0,0 +1,22 @@ +.note { + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; +} + +.note .note-header .profile { + font-size: 14px; +} + +.note .note-avatar { + width: 18px; + height: 18px; +} + +.note .note-content { + margin-left: 30px; +} + +.note .note-content .markdown > p { + font-size: 14px; +} diff --git a/src/element/note.tsx b/src/element/note.tsx new file mode 100644 index 0000000..b79987d --- /dev/null +++ b/src/element/note.tsx @@ -0,0 +1,18 @@ +import "./note.css"; +import { type NostrEvent } from "@snort/system"; + +import { Markdown } from "element/markdown"; +import { Profile } from "element/profile"; + +export function Note({ ev }: { ev: NostrEvent }) { + return ( +
    +
    + +
    +
    + {ev.content} +
    +
    + ); +} diff --git a/src/element/stream-cards.css b/src/element/stream-cards.css new file mode 100644 index 0000000..37bfdbd --- /dev/null +++ b/src/element/stream-cards.css @@ -0,0 +1,128 @@ +.stream-cards { + display: none; +} + +@media (min-width: 1020px) { + .stream-cards { + display: flex; + align-items: flex-start; + gap: 24px; + margin-top: 12px; + flex-wrap: wrap; + } +} + +.card-container { + display: flex; + flex-direction: column; + gap: 12px; +} + +.editor-buttons { + display: flex; + flex-direction: column; + gap: 12px; +} + +.stream-card { + display: flex; + align-self: flex-start; + flex-direction: column; + padding: 20px 24px; + gap: 16px; + border-radius: 24px; + background: #111; + width: 210px; +} + +.stream-card .card-title { + margin: 0; + font-size: 22px; + font-style: normal; + font-weight: 600; + line-height: normal; +} + +@media (min-width: 1900px) { + .stream-card { + width: 342px; + } +} + +.add-card { + align-items: center; + justify-content: center; +} + +.add-card .add-icon { + color: #797979; + cursor: pointer; + width: 24px; + height: 24px; +} + +.new-card { + display: flex; + flex-direction: column; + gap: 12px; +} + +.new-card h3 { + margin: 0; + margin-bottom: 12px; +} + +.new-card input[type="text"] { + background: #262626; + padding: 8px 16px; + border-radius: 16px; + width: unset; + margin-bottom: 8px; + font-size: 16px; + font-weight: 500; + line-height: 20px; +} + + +.new-card textarea { + width: unset; + background: #262626; + padding: 8px 16px; + border-radius: 16px; + margin-bottom: 8px; +} + +.form-control { + display: flex; + flex-direction: column; +} + +.form-control label { + margin-bottom: 8px; +} + +.new-card-buttons { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 12px; +} + +.help-text { + color: var(--text-muted); + font-size: 14px; + margin-left: 6px; +} + +.help-text a { + color: var(--text-link); +} + +.add-button { + height: 50px; +} + +.delete-button { + background: transparent; + color: var(--text-danger); +} diff --git a/src/element/stream-cards.tsx b/src/element/stream-cards.tsx new file mode 100644 index 0000000..608beba --- /dev/null +++ b/src/element/stream-cards.tsx @@ -0,0 +1,305 @@ +import "./stream-cards.css"; + +import { useState } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; + +import type { NostrEvent } from "@snort/system"; + +import { useLogin } from "hooks/login"; +import { useCards } from "hooks/cards"; +import { CARD, USER_CARDS } from "const"; +import { toTag } from "utils"; +import { System } from "index"; +import { findTag } from "utils"; +import { Icon } from "./icon"; +import { ExternalLink } from "./external-link"; +import { FileUploader } from "./file-uploader"; +import { Markdown } from "./markdown"; + +interface CardType { + identifier?: string; + title?: string; + image?: string; + link?: string; + content: string; +} + +interface CardProps { + canEdit?: boolean; + ev: NostrEvent; + cards: NostrEvent[]; +} + +function Card({ canEdit, ev, cards }: CardProps) { + const identifier = findTag(ev, "d"); + const title = findTag(ev, "title") || findTag(ev, "subject"); + const image = findTag(ev, "image"); + const link = findTag(ev, "r"); + const evCard = { title, image, link, content: ev.content, identifier }; + + const card = ( + <> +
    + {title &&

    {title}

    } + {image && {title}} + +
    + + ); + const editor = canEdit && ( +
    + + +
    + ); + return link && !canEdit ? ( +
    + {card} + {editor} +
    + ) : ( +
    + {card} + {editor} +
    + ); +} + +interface CardDialogProps { + header?: string; + cta?: string; + card?: CardType; + onSave(ev: CardType): void; + onCancel(): void; +} + +function CardDialog({ header, cta, card, onSave, onCancel }: CardDialogProps) { + const [title, setTitle] = useState(card?.title ?? ""); + const [image, setImage] = useState(card?.image ?? ""); + const [content, setContent] = useState(card?.content ?? ""); + const [link, setLink] = useState(card?.link ?? ""); + + return ( +
    +

    {header || "Add card"}

    +
    + + setTitle(e.target.value)} + placeholder="e.g. about me" + /> +
    +
    + + +
    +
    + + setLink(e.target.value)} + /> +
    +
    + +