forked from Kieran/zap.stream
Merge pull request 'feat: render emoji packs and goals in cards' (#42) from nostr-embeds into cards
Reviewed-on: Kieran/stream#42
This commit is contained in:
commit
52b78e1fa3
@ -2,6 +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
19
src/element/Address.tsx
Normal 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
33
src/element/Event.tsx
Normal 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
0
src/element/address.css
Normal 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 && (
|
||||
|
32
src/element/emoji-pack.css
Normal file
32
src/element/emoji-pack.css
Normal 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;
|
||||
}
|
29
src/element/emoji-pack.tsx
Normal file
29
src/element/emoji-pack.tsx
Normal 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
8
src/element/event.css
Normal file
@ -0,0 +1,8 @@
|
||||
.event-container .goal {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.event-container .goal .amount {
|
||||
top: -8px;
|
||||
font-size: 10px;
|
||||
}
|
@ -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
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -1,8 +1,8 @@
|
||||
.markdown a {
|
||||
.markdown a {
|
||||
color: var(--text-link);
|
||||
}
|
||||
|
||||
.markdown ul, .markdown ol {
|
||||
.markdown > ul, .markdown > ol {
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
font-size: 18px;
|
||||
@ -10,7 +10,7 @@
|
||||
line-height: 29px;
|
||||
}
|
||||
|
||||
.markdown p {
|
||||
.markdown > p {
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
overflow-wrap: break-word;
|
||||
@ -18,7 +18,7 @@
|
||||
line-height: 29px; /* 161.111% */
|
||||
}
|
||||
|
||||
.markdown img {
|
||||
.markdown > img {
|
||||
max-height: 230px;
|
||||
width: 100%;
|
||||
}
|
||||
|
@ -1,11 +1,216 @@
|
||||
import "./markdown.css";
|
||||
|
||||
import { parseNostrLink } from "@snort/system";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
export function Markdown({ children }) {
|
||||
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} />
|
||||
<ReactMarkdown children={children} components={components} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
22
src/element/note.css
Normal file
22
src/element/note.css
Normal 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
18
src/element/note.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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
43
src/hooks/event.ts
Normal 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;
|
||||
}
|
@ -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;
|
||||
|
@ -15,6 +15,7 @@ body {
|
||||
--text-muted: #797979;
|
||||
--text-link: #F838D9;
|
||||
--text-danger: #FF563F;
|
||||
--border: #333;
|
||||
}
|
||||
|
||||
@media(max-width: 1020px) {
|
||||
@ -270,3 +271,9 @@ div.paper {
|
||||
.szh-menu__item--hover {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user