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:
Kieran 2023-07-26 20:52:09 +00:00
commit 52b78e1fa3
21 changed files with 555 additions and 77 deletions

View File

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

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

View File

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

View File

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

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

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

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>