Merge pull request 'feat: cards' (#44) from cards into main

Reviewed-on: Kieran/stream#44
This commit is contained in:
2023-07-26 20:53:26 +00:00
33 changed files with 1873 additions and 82 deletions

View File

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

19
src/element/Address.tsx Normal file
View File

@ -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 <EmojiPack ev={event} />;
}
return null;
}

33
src/element/Event.tsx Normal file
View File

@ -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 (
<div className="event-container">
<Goal ev={event} />
</div>
);
}
if (event && event.kind === EventKind.TextNote) {
return (
<div className="event-container">
<Note ev={event} />
</div>
);
}
return <code>{link.id}</code>;
}

0
src/element/address.css Normal file
View File

View File

@ -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 && (

View File

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

View File

@ -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 (
<div className="emoji-pack">
<div className="emoji-pack-title">
<h4>{name}</h4>
<Mention pubkey={ev.pubkey} />
</div>
<div className="emoji-pack-emojis">
{emoji.map((e) => {
const [, name, image] = e;
return (
<div className="emoji-definition">
<img alt={name} className="emoji" src={image} />
<span className="emoji-name">{name}</span>
</div>
);
})}
</div>
</div>
);
}

8
src/element/event.css Normal file
View File

@ -0,0 +1,8 @@
.event-container .goal {
font-size: 14px;
}
.event-container .goal .amount {
top: -8px;
font-size: 10px;
}

View File

@ -0,0 +1,7 @@
export function ExternalLink({ children, href }) {
return (
<a href={href} rel="noopener noreferrer" target="_blank">
{children}
</a>
)
}

View File

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

View File

@ -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<UploadResult> {
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 (
<div className="file-uploader-container">
<label className="file-uploader">
<input type="file" onChange={onFileChange} />
{isUploading ? "Uploading..." : "Add File"}
</label>
{img && <img className="image-preview" src={img} />}
</div>
);
}

View File

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

View File

@ -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 (
<div className="goal">
const goalContent = (
<div className="goal" style={{ cursor: zapTarget ? "pointer" : "auto" }}>
{ev.content.length > 0 && <p>{ev.content}</p>}
<div className={`progress-container ${isFinished ? "finished" : ""}`}>
<Progress.Root className="progress-root" value={progress}>
@ -61,4 +65,16 @@ export function Goal({
)}
</div>
);
return zapTarget ? (
<SendZapsDialog
lnurl={zapTarget}
pubkey={ev.pubkey}
eTag={ev?.id}
targetName={getName(ev.pubkey, profile)}
button={goalContent}
/>
) : (
goalContent
);
}

View File

@ -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 (
<img
src={url.toString()}
alt={url.toString()}
objectFit="contain"
/>
);
}
case "wav":
case "mp3":
case "ogg": {
return <audio key={url.toString()} src={url.toString()} controls />;
}
case "mp4":
case "mov":
case "mkv":
case "avi":
case "m4v":
case "webm": {
return <video key={url.toString()} src={url.toString()} controls />;
}
default:
return <a href={url.toString()}>{children || url.toString()}</a>;
}
} else if (url.protocol === "nostr:" || url.protocol === "web+nostr:") {
return <NostrLink link={link} />;
} else {
<a href={link} target="_blank" rel="noreferrer">
{link}
{children}
</a>;
}
} catch {
} catch (error) {
console.error(error);
// Ignore the error.
}
return (
<a href={link} target="_blank" rel="noreferrer">
{link}
{children}
</a>
);
}

View File

@ -309,12 +309,6 @@
line-height: 22px;
}
.message-reaction .emoji {
width: 15px;
height: 15px;
margin-bottom: -2px;
}
.zap-pill-amount {
text-transform: lowercase;
color: #FFF;
@ -347,4 +341,4 @@
.write-emoji-button:hover {
color: white;
}
}

View File

@ -88,20 +88,9 @@ export function LiveChat({
const zaps = feed.zaps
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
.filter((z) => z && z.valid);
const goalZaps = feed.zaps
.filter((ev) =>
goal
? ev.created_at > goal.created_at &&
ev.tags.some((t) => t[0] === "e" && t[1] === goal.id)
: false
)
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
.filter((z) => z && z.valid);
const events = useMemo(() => {
return [...feed.messages, ...feed.zaps].sort(
(a, b) => b.created_at - a.created_at
(a, b) => b.created_at - a.created_at,
);
}, [feed.messages, feed.zaps]);
const streamer = getHost(ev);
@ -112,7 +101,7 @@ export function LiveChat({
findTag(ev, "d") ?? "",
undefined,
ev.kind,
ev.pubkey
ev.pubkey,
);
}
}, [ev]);
@ -125,7 +114,13 @@ export function LiveChat({
<Icon
name="link"
size={32}
onClick={() => window.open(`/chat/${naddr}?chat=true`, "_blank", "popup,width=400,height=800")}
onClick={() =>
window.open(
`/chat/${naddr}?chat=true`,
"_blank",
"popup,width=400,height=800",
)
}
/>
</div>
)}
@ -135,7 +130,7 @@ export function LiveChat({
<div className="top-zappers-container">
<TopZappers zaps={zaps} />
</div>
{goal && <Goal ev={goal} zaps={goalZaps} />}
{goal && <Goal ev={goal} />}
{login?.pubkey === streamer && <NewGoalDialog link={link} />}
</div>
)}
@ -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 <ChatZap zap={zap} key={a.id} />;
@ -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 }) {
<span className="zap-amount">{formatSats(zap.amount)}</span>
sats
</div>
{zap.content && (
{zap.content && (
<div className="zap-content">
<Text content={zap.content} tags={[]} />
</div>

24
src/element/markdown.css Normal file
View File

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

216
src/element/markdown.tsx Normal file
View File

@ -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 <Emoji name={t[1]} url={t[2]} />;
} 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 <Mention key={ref[1]} pubkey={ref[1]} />;
}
case "a": {
return <Address link={parseNostrLink(ref[1])} />;
}
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 <Mention key={link.id} pubkey={link.id} />;
} 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 <Mention key={link.id} pubkey={link.id} />;
} 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 <Event link={link} />;
} 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 <Address key={i} link={link} />;
} 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 <Event link={link} />;
} 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 && <li {...props}>{transformText(children, tags)}</li>;
},
td: ({ children }) =>
children && <td>{transformText(children, tags)}</td>,
p: ({ children }) => children && <p>{transformText(children, tags)}</p>,
a: (props) => {
return <HyperText link={props.href}>{props.children}</HyperText>;
},
};
}, [tags]);
return (
<div className="markdown">
<ReactMarkdown children={children} components={components} />
</div>
);
}

22
src/element/note.css Normal file
View File

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

18
src/element/note.tsx Normal file
View File

@ -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 (
<div className="note">
<div className="note-header">
<Profile avatarClassname="note-avatar" pubkey={ev.pubkey} />
</div>
<div className="note-content">
<Markdown tags={ev.tags}>{ev.content}</Markdown>
</div>
</div>
);
}

View File

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

View File

@ -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 = (
<>
<div className="stream-card">
{title && <h1 className="card-title">{title}</h1>}
{image && <img src={image} alt={title} />}
<Markdown children={ev.content} />
</div>
</>
);
const editor = canEdit && (
<div className="editor-buttons">
<EditCard card={evCard} />
<DeleteCard card={ev} cards={cards} />
</div>
);
return link && !canEdit ? (
<div className="card-container">
<ExternalLink href={link}>{card}</ExternalLink>
{editor}
</div>
) : (
<div className="card-container">
{card}
{editor}
</div>
);
}
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 (
<div className="new-card">
<h3>{header || "Add card"}</h3>
<div className="form-control">
<label for="card-title">Title</label>
<input
id="card-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. about me"
/>
</div>
<div className="form-control">
<label for="card-image">Image</label>
<FileUploader onFileUpload={setImage} />
</div>
<div className="form-control">
<label for="card-image-link">Link</label>
<input
id="card-image-link"
type="text"
placeholder="https://"
value={link}
onChange={(e) => setLink(e.target.value)}
/>
</div>
<div className="form-control">
<label for="card-content">Content</label>
<textarea
placeholder="Start typing..."
value={content}
onChange={(e) => setContent(e.target.value)}
/>
<span className="help-text">
Supports{" "}
<ExternalLink href="https://www.markdownguide.org/cheat-sheet">
Markdown
</ExternalLink>
</span>
</div>
<div className="new-card-buttons">
<button
className="btn btn-primary add-button"
onClick={() => onSave({ title, image, content, link })}
>
{cta || "Add Card"}
</button>
<button className="btn delete-button" onClick={onCancel}>
Cancel
</button>
</div>
</div>
);
}
interface EditCardProps {
card: CardType;
}
function EditCard({ card }: EditCardProps) {
const login = useLogin();
const [isOpen, setIsOpen] = useState(false);
async function editCard({ title, image, link, content }) {
const pub = login?.publisher();
if (pub) {
const ev = await pub.generic((eb) => {
eb.kind(CARD).content(content).tag(["d", card.identifier]);
if (title?.length > 0) {
eb.tag(["title", title]);
}
if (image?.length > 0) {
eb.tag(["image", image]);
}
if (link?.lenght > 0) {
eb.tag(["r", link]);
}
return eb;
});
console.debug(ev);
System.BroadcastEvent(ev);
setIsOpen(false);
}
}
function onCancel() {
setIsOpen(false);
}
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
<button className="btn btn-primary">Edit</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<CardDialog
header="Edit card"
cta="Save Card"
card={card}
onSave={editCard}
onCancel={onCancel}
/>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
interface DeleteCardProps {
card: NostrEvent;
cards: NostrEvent[];
}
function DeleteCard({ card, cards }: DeleteCardProps) {
const login = useLogin();
const tags = cards.map(toTag);
async function deleteCard() {
const pub = login?.publisher();
if (pub) {
const userCardsEv = await pub.generic((eb) => {
eb.kind(USER_CARDS).content("");
for (const tag of tags) {
if (tag.at(1) !== toTag(card).at(1)) {
eb.tag(tag);
}
}
return eb;
});
console.log(userCardsEv);
System.BroadcastEvent(userCardsEv);
}
}
return (
<button className="btn delete-button" onClick={deleteCard}>
Delete
</button>
);
}
interface AddCardProps {
cards: NostrEvent[];
}
function AddCard({ cards }: AddCardProps) {
const login = useLogin();
const tags = cards.map(toTag);
const [isOpen, setIsOpen] = useState(false);
async function createCard({ title, image, link, content }) {
const pub = login?.publisher();
if (pub) {
const ev = await pub.generic((eb) => {
const d = String(Date.now());
eb.kind(CARD).content(content).tag(["d", d]);
if (title?.length > 0) {
eb.tag(["title", title]);
}
if (image?.length > 0) {
eb.tag(["image", image]);
}
if (link?.length > 0) {
eb.tag(["r", link]);
}
return eb;
});
const userCardsEv = await pub.generic((eb) => {
eb.kind(USER_CARDS).content("");
for (const tag of tags) {
eb.tag(tag);
}
eb.tag(toTag(ev));
return eb;
});
console.debug(ev);
console.debug(userCardsEv);
System.BroadcastEvent(ev);
System.BroadcastEvent(userCardsEv);
setIsOpen(false);
}
}
function onCancel() {
setIsOpen(false);
}
return (
<div className="stream-card add-card">
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
<Icon name="plus" className="add-icon" />
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<CardDialog onSave={createCard} onCancel={onCancel} />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</div>
);
}
export function StreamCards({ host }) {
const login = useLogin();
const canEdit = login?.pubkey === host;
const cards = useCards(host, canEdit);
return (
<div className="stream-cards">
{cards.map((ev) => (
<Card canEdit={canEdit} cards={cards} key={ev.id} ev={ev} />
))}
{canEdit && <AddCard cards={cards} />}
</div>
);
}

86
src/hooks/cards.ts Normal file
View File

@ -0,0 +1,86 @@
import { useMemo } from "react";
import {
ReplaceableNoteStore,
NoteCollection,
RequestBuilder,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { USER_CARDS, CARD } from "const";
import { findTag } from "utils";
import { System } from "index";
export function useCards(pubkey: string, leaveOpen = false) {
const sub = useMemo(() => {
const b = new RequestBuilder(`user-cards:${pubkey.slice(0, 12)}`);
b.withOptions({
leaveOpen,
})
.withFilter()
.authors([pubkey])
.kinds([USER_CARDS]);
return b;
}, [pubkey, leaveOpen]);
const { data: userCards } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub,
);
const related = useMemo(() => {
// filtering to only show CARD kinds for now, but in the future we could link and render anything
if (userCards) {
return userCards.tags.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<NoteCollection>(
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;
}

View File

@ -1,6 +1,5 @@
import {
RequestBuilder,
EventKind,
ReplaceableNoteStore,
NoteCollection,
NostrEvent,
@ -9,6 +8,7 @@ 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";
@ -49,9 +49,7 @@ export default function useEmoji(pubkey?: string) {
if (!pubkey) return null;
const rb = new RequestBuilder(`emoji:${pubkey}`);
rb.withFilter()
.authors([pubkey])
.kinds([10030 as EventKind]);
rb.withFilter().authors([pubkey]).kinds([USER_EMOJIS]);
return rb;
}, [pubkey]);
@ -59,13 +57,13 @@ export default function useEmoji(pubkey?: string) {
const { data: userEmoji } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub
sub,
);
const related = useMemo(() => {
if (userEmoji) {
return userEmoji.tags.filter(
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`30030:`)
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${EMOJI_PACK}:`),
);
}
return [];
@ -85,14 +83,9 @@ export default function useEmoji(pubkey?: string) {
const rb = new RequestBuilder(`emoji-related:${pubkey}`);
rb.withFilter()
.kinds([30030 as EventKind])
.authors(authors)
.tag("d", identifiers);
rb.withFilter().kinds([EMOJI_PACK]).authors(authors).tag("d", identifiers);
rb.withFilter()
.kinds([30030 as EventKind])
.authors([pubkey]);
rb.withFilter().kinds([EMOJI_PACK]).authors([pubkey]);
return rb;
}, [pubkey, related]);
@ -100,7 +93,7 @@ export default function useEmoji(pubkey?: string) {
const { data: relatedData } = useRequestBuilder<NoteCollection>(
System,
NoteCollection,
subRelated
subRelated,
);
const emojiPacks = useMemo(() => {

43
src/hooks/event.ts Normal file
View File

@ -0,0 +1,43 @@
import { useMemo } from "react";
import {
NostrPrefix,
ReplaceableNoteStore,
RequestBuilder,
type NostrLink,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { System } from "index";
export function useEvent(link: NostrLink) {
const sub = useMemo(() => {
const b = new RequestBuilder(`event:${link.id.slice(0, 12)}`);
if (link.type === NostrPrefix.Address) {
const f = b.withFilter().tag("d", [link.id]);
if (link.author) {
f.authors([link.author]);
}
if (link.kind) {
f.kinds([link.kind]);
}
} else {
const f = b.withFilter().ids([link.id]);
if (link.relays) {
link.relays.slice(0, 2).forEach((r) => f.relay(r));
}
if (link.author) {
f.authors([link.author]);
}
}
return b;
}, [link]);
const { data } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub,
);
return data;
}

View File

@ -1,13 +1,37 @@
import { useMemo } from "react";
import {
EventKind,
NostrEvent,
RequestBuilder,
NoteCollection,
ReplaceableNoteStore,
NostrLink,
parseZap,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { GOAL } from "const";
import { System } from "index";
export function useZaps(goal: NostrEvent, leaveOpen = false) {
const sub = useMemo(() => {
const b = new RequestBuilder(`goal-zaps:${goal.id.slice(0, 12)}`);
b.withOptions({ leaveOpen });
b.withFilter()
.kinds([EventKind.ZapReceipt])
.tag("e", [goal.id])
.since(goal.created_at);
return b;
}, [goal, leaveOpen]);
const { data } = useRequestBuilder<NoteCollection>(
System,
NoteCollection,
sub,
);
return data?.map((ev) => parseZap(ev, System.ProfileLoader.Cache)).filter((z) => z && z.valid) ?? [];
}
export function useZapGoal(host: string, link: NostrLink, leaveOpen = false) {
const sub = useMemo(() => {
const b = new RequestBuilder(`goals:${host.slice(0, 12)}`);
@ -22,7 +46,7 @@ export function useZapGoal(host: string, link: NostrLink, leaveOpen = false) {
const { data } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub
sub,
);
return data;

View File

@ -12,6 +12,10 @@ body {
--gap-m: 24px;
--gap-s: 16px;
--header-height: 48px;
--text-muted: #797979;
--text-link: #F838D9;
--text-danger: #FF563F;
--border: #333;
}
@media(max-width: 1020px) {
@ -267,3 +271,9 @@ div.paper {
.szh-menu__item--hover {
background-color: unset;
}
.emoji {
width: 15px;
height: 15px;
margin-bottom: -2px;
}

View File

@ -62,7 +62,7 @@ export function ProfilePage() {
}, [streams]);
const futureStreams = useMemo(() => {
return streams.filter(
(ev) => findTag(ev, "status") === StreamState.Planned
(ev) => findTag(ev, "status") === StreamState.Planned,
);
}, [streams]);
const isLive = Boolean(liveEvent);
@ -75,7 +75,7 @@ export function ProfilePage() {
d,
undefined,
liveEvent.kind,
liveEvent.pubkey
liveEvent.pubkey,
);
navigate(`/${naddr}`);
}
@ -114,7 +114,7 @@ export function ProfilePage() {
liveEvent
? `${liveEvent.kind}:${liveEvent.pubkey}:${findTag(
liveEvent,
"d"
"d",
)}`
: undefined
}
@ -171,7 +171,7 @@ export function ProfilePage() {
<span className="timestamp">
Streamed on{" "}
{moment(Number(ev.created_at) * 1000).format(
"MMM DD, YYYY"
"MMM DD, YYYY",
)}
</span>
</div>
@ -186,7 +186,7 @@ export function ProfilePage() {
<span className="timestamp">
Scheduled for{" "}
{moment(Number(ev.created_at) * 1000).format(
"MMM DD, YYYY h:mm:ss a"
"MMM DD, YYYY h:mm:ss a",
)}
</span>
</div>

View File

@ -18,6 +18,7 @@ import { useUserProfile } from "@snort/system-react";
import { NewStreamDialog } from "element/new-stream";
import { Tags } from "element/tags";
import { StatePill } from "element/state-pill";
import { StreamCards } from "element/stream-cards";
import { formatSats } from "number";
import { StreamTimer } from "element/stream-time";
import { ShareMenu } from "element/share-menu";
@ -135,6 +136,7 @@ export function StreamPage() {
<div className="video-content">
<LiveVideoPlayer stream={stream} poster={image} status={status} />
<ProfileInfo ev={ev} goal={goal} />
<StreamCards host={host} />
</div>
<LiveChat link={link} ev={ev} goal={goal} />
</div>

View File

@ -2,6 +2,20 @@ import { NostrEvent, NostrPrefix, encodeTLV } from "@snort/system";
import * as utils from "@noble/curves/abstract/utils";
import { bech32 } from "@scure/base";
export function toTag(e: NostrEvent): string[] {
if (e.kind && e.kind >= 30000 && e.kind <= 40000) {
const dTag = findTag(e, "d");
return ["a", `${e.kind}:${e.pubkey}:${dTag}`];
}
if (e.kind === 0 || e.kind === 3) {
return ["p", e.pubkey];
}
return ["e", e.id];
}
export function findTag(e: NostrEvent | undefined, tag: string) {
const maybeTag = e?.tags.find((evTag) => {
return evTag[0] === tag;
@ -48,17 +62,21 @@ export function eventLink(ev: NostrEvent) {
d,
undefined,
ev.kind,
ev.pubkey
ev.pubkey,
);
return `/${naddr}`;
}
export function getHost(ev?: NostrEvent) {
return ev?.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev?.pubkey ?? "";
return (
ev?.tags.find((a) => a[0] === "p" && a[3] === "host")?.[1] ??
ev?.pubkey ??
""
);
}
export async function openFile(): Promise<File | undefined> {
return new Promise(resolve => {
return new Promise((resolve) => {
const elm = document.createElement("input");
elm.type = "file";
elm.onchange = (e: Event) => {
@ -71,4 +89,4 @@ export async function openFile(): Promise<File | undefined> {
};
elm.click();
});
}
}