feat: message zaps

This commit is contained in:
Alejandro Gomez 2023-07-02 19:53:13 +02:00
parent 3224b03a98
commit a48991c13e
No known key found for this signature in database
GPG Key ID: 4DF39E566658C817
6 changed files with 204 additions and 28 deletions

View File

@ -22,6 +22,7 @@
"react-intersection-observer": "^9.5.1", "react-intersection-observer": "^9.5.1",
"react-router-dom": "^6.13.0", "react-router-dom": "^6.13.0",
"semantic-sdp": "^3.26.2", "semantic-sdp": "^3.26.2",
"usehooks-ts": "^2.9.1",
"web-vitals": "^2.1.0", "web-vitals": "^2.1.0",
"webrtc-adapter": "^8.2.3" "webrtc-adapter": "^8.2.3"
}, },

View File

@ -92,6 +92,7 @@
.live-chat .message { .live-chat .message {
word-wrap: break-word; word-wrap: break-word;
position: relative;
} }
.live-chat .message .profile { .live-chat .message .profile {
@ -217,3 +218,53 @@
.zap-content { .zap-content {
margin-top: 8px; margin-top: 8px;
} }
.zap-pill {
display: flex;
align-items: center;
margin-top: 4px;
padding: 0 4px;
justify-content: center;
gap: 2px;
border-radius: 8px;
background: #434343;
width: fit-content;
}
.zap-pill-icon {
width: 12px;
height: 12px;
color: #FF8D2B;
}
.zap-pill-amount {
color: #FFF;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 18px;
text-transform: lowercase;
}
.message-zap-button {
cursor: pointer;
position: absolute;
left: 12px;
top: -6px;
background: transparent;
border: none;
display: flex;
background: #434343;
border-radius: 8px;
width: 16px;
height: 16px;
justify-content: center;
align-items: center;
}
.message-zap-button-icon {
color: #FF8D2B;
width: 12px;
height: 12px;
flex-shrink: 0;
}

View File

@ -10,9 +10,13 @@ import {
import { import {
useState, useState,
useEffect, useEffect,
useMemo,
useRef,
type KeyboardEvent, type KeyboardEvent,
type ChangeEvent, type ChangeEvent,
type LegacyRef,
} from "react"; } from "react";
import { useHover } from "usehooks-ts";
import useEmoji from "hooks/emoji"; import useEmoji from "hooks/emoji";
import { System } from "index"; import { System } from "index";
@ -23,10 +27,12 @@ import { Icon } from "./icon";
import { Text } from "./text"; import { Text } from "./text";
import { Textarea } from "./textarea"; import { Textarea } from "./textarea";
import Spinner from "./spinner"; import Spinner from "./spinner";
import { SendZapsDialog } from "./send-zap";
import { useLogin } from "hooks/login"; import { useLogin } from "hooks/login";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import { formatSats } from "number"; import { formatSats } from "number";
import useTopZappers from "hooks/top-zappers"; import useTopZappers from "hooks/top-zappers";
import { LIVE_STREAM_CHAT } from "const";
export interface LiveChatOptions { export interface LiveChatOptions {
canWrite?: boolean; canWrite?: boolean;
@ -67,13 +73,22 @@ export function LiveChat({
options?: LiveChatOptions; options?: LiveChatOptions;
height?: number; height?: number;
}) { }) {
const messages = useLiveChatFeed(link); const feed = useLiveChatFeed(link);
const login = useLogin(); const login = useLogin();
const events = messages.data ?? []; const zaps = feed.zaps
const zaps = events
.filter((ev) => ev.kind === EventKind.ZapReceipt) .filter((ev) => ev.kind === EventKind.ZapReceipt)
.map((ev) => parseZap(ev, System.ProfileLoader.Cache)) .map((ev) => parseZap(ev, System.ProfileLoader.Cache))
.filter((z) => z && z.valid); .filter((z) => z && z.valid);
const reactions = feed.reactions
.filter((ev) => ev.kind === EventKind.ZapReceipt)
.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
);
}, [feed.messages, feed.zaps]);
return ( return (
<div className="live-chat" style={height ? { height: `${height}px` } : {}}> <div className="live-chat" style={height ? { height: `${height}px` } : {}}>
{(options?.showHeader ?? true) && ( {(options?.showHeader ?? true) && (
@ -85,20 +100,26 @@ export function LiveChat({
</div> </div>
)} )}
<div className="messages"> <div className="messages">
{[...(messages.data ?? [])] {events.map((a) => {
.sort((a, b) => b.created_at - a.created_at)
.map((a) => {
switch (a.kind) { switch (a.kind) {
case 1311: { case LIVE_STREAM_CHAT: {
return <ChatMessage ev={a} link={link} key={a.id} />; return (
<ChatMessage
streamer={link.author ?? ""}
ev={a}
link={link}
key={a.id}
reactions={reactions}
/>
);
} }
case EventKind.ZapReceipt: { case EventKind.ZapReceipt: {
return <ChatZap ev={a} key={a.id} />; return <ChatZap streamer={link.author ?? ""} ev={a} key={a.id} />;
} }
} }
return null; return null;
})} })}
{messages.data === undefined && <Spinner />} {feed.messages.length === 0 && <Spinner />}
</div> </div>
{(options?.canWrite ?? true) && ( {(options?.canWrite ?? true) && (
<div className="write-message"> <div className="write-message">
@ -113,16 +134,72 @@ export function LiveChat({
); );
} }
function ChatMessage({ ev, link }: { ev: TaggedRawEvent; link: NostrLink }) { function ChatMessage({
streamer,
ev,
link,
reactions,
}: {
streamer: string;
ev: TaggedRawEvent;
link: NostrLink;
reactions: ParsedZap[];
}) {
const ref = useRef(null);
const isHovering = useHover(ref);
const profile = useUserProfile(System, ev.pubkey);
const zapTarget = profile?.lud16 ?? profile?.lud06;
const totalZaps = useMemo(() => {
const messageZaps = reactions.filter((z) => z.event === ev.id);
return messageZaps.reduce((acc, z) => acc + z.amount, 0);
}, [reactions, ev]);
return ( return (
<div className={`message${link.author === ev.pubkey ? " streamer" : ""}`}> <>
<div
className={`message${link.author === ev.pubkey ? " streamer" : ""}`}
ref={ref}
>
{zapTarget && (
<SendZapsDialog
lnurl={zapTarget}
aTag={
streamer === ev.pubkey
? `${link.kind}:${link.author}:${link.id}`
: undefined
}
eTag={ev.id}
pubkey={ev.pubkey}
button={
isHovering ? (
<div className="message-zap-container">
<button className="message-zap-button">
<Icon
name="zap-filled"
className="message-zap-button-icon"
/>
</button>
</div>
) : (
<></>
)
}
targetName={profile?.name || ev.pubkey}
/>
)}
<Profile pubkey={ev.pubkey} /> <Profile pubkey={ev.pubkey} />
<Text content={ev.content} tags={ev.tags} /> <Text content={ev.content} tags={ev.tags} />
{totalZaps !== 0 && (
<div className="zap-pill">
<Icon name="zap-filled" className="zap-pill-icon" />
<span className="zap-pill-amount">{formatSats(totalZaps)}</span>
</div> </div>
)}
</div>
</>
); );
} }
function ChatZap({ ev }: { ev: TaggedRawEvent }) { function ChatZap({ streamer, ev }: { streamer: string; ev: TaggedRawEvent }) {
const parsed = parseZap(ev, System.ProfileLoader.Cache); const parsed = parseZap(ev, System.ProfileLoader.Cache);
useUserProfile(System, parsed.anonZap ? undefined : parsed.sender); useUserProfile(System, parsed.anonZap ? undefined : parsed.sender);
@ -141,7 +218,8 @@ function ChatZap({ ev }: { ev: TaggedRawEvent }) {
if (!parsed.valid) { if (!parsed.valid) {
return null; return null;
} }
return (
return parsed.receiver === streamer ? (
<div className="zap-container"> <div className="zap-container">
<div className="zap"> <div className="zap">
<Icon name="zap-filled" className="zap-icon" /> <Icon name="zap-filled" className="zap-icon" />
@ -158,7 +236,7 @@ function ChatZap({ ev }: { ev: TaggedRawEvent }) {
</div> </div>
{parsed.content && <div className="zap-content">{parsed.content}</div>} {parsed.content && <div className="zap-content">{parsed.content}</div>}
</div> </div>
); ) : null;
} }
function WriteMessage({ link }: { link: NostrLink }) { function WriteMessage({ link }: { link: NostrLink }) {
@ -184,7 +262,7 @@ function WriteMessage({ link }: { link: NostrLink }) {
const emoji = [...emojiNames].map((name) => const emoji = [...emojiNames].map((name) =>
emojis.find((e) => e.at(1) === name) emojis.find((e) => e.at(1) === name)
); );
eb.kind(1311 as EventKind) eb.kind(LIVE_STREAM_CHAT as EventKind)
.content(chat) .content(chat)
.tag(["a", `${link.kind}:${link.author}:${link.id}`, "", "root"]) .tag(["a", `${link.kind}:${link.author}:${link.id}`, "", "root"])
.processContent(); .processContent();

View File

@ -13,6 +13,7 @@ interface SendZapsProps {
lnurl: string; lnurl: string;
pubkey?: string; pubkey?: string;
aTag?: string; aTag?: string;
eTag?: string;
targetName?: string; targetName?: string;
onFinish: () => void; onFinish: () => void;
button?: ReactNode; button?: ReactNode;
@ -22,6 +23,7 @@ function SendZaps({
lnurl, lnurl,
pubkey, pubkey,
aTag, aTag,
eTag,
targetName, targetName,
onFinish, onFinish,
}: SendZapsProps) { }: SendZapsProps) {
@ -57,7 +59,7 @@ function SendZaps({
const amountInSats = isFiat ? Math.floor((amount / UsdRate) * 1e8) : amount; const amountInSats = isFiat ? Math.floor((amount / UsdRate) * 1e8) : amount;
let zap: NostrEvent | undefined; let zap: NostrEvent | undefined;
if (pubkey && aTag) { if (pubkey) {
zap = await pub.zap( zap = await pub.zap(
amountInSats * 1000, amountInSats * 1000,
pubkey, pubkey,
@ -65,7 +67,13 @@ function SendZaps({
undefined, undefined,
comment, comment,
(eb) => { (eb) => {
return eb.tag(["a", aTag]); if (aTag) {
eb.tag(["a", aTag]);
}
if (eTag) {
eb.tag(["e", eTag]);
}
return eb;
} }
); );
} }

View File

@ -22,5 +22,38 @@ export function useLiveChatFeed(link: NostrLink) {
return rb; return rb;
}, [link]); }, [link]);
return useRequestBuilder<FlatNoteStore>(System, FlatNoteStore, sub); const feed = useRequestBuilder<FlatNoteStore>(System, FlatNoteStore, sub);
const messages = useMemo(() => {
return (feed.data ?? []).filter((ev) => ev.kind === LIVE_STREAM_CHAT);
}, [feed.data]);
const zaps = useMemo(() => {
return (feed.data ?? []).filter((ev) => ev.kind === EventKind.ZapReceipt);
}, [feed.data]);
const etags = useMemo(() => {
return messages.map((e) => e.id);
}, [messages]);
const esub = useMemo(() => {
if (etags.length === 0) return null;
const rb = new RequestBuilder(`msg-zaps:${link.id}:${link.author}`);
rb.withOptions({
leaveOpen: true,
});
rb.withFilter()
.kinds([EventKind.Reaction, EventKind.ZapReceipt])
.tag("e", etags);
return rb;
}, [etags]);
const relatedZaps = useRequestBuilder<FlatNoteStore>(
System,
FlatNoteStore,
esub
);
const reactions = relatedZaps.data ?? [];
return { messages, zaps, reactions };
} }

View File

@ -6323,6 +6323,11 @@ use-sidecar@^1.1.2:
detect-node-es "^1.1.0" detect-node-es "^1.1.0"
tslib "^2.0.0" tslib "^2.0.0"
usehooks-ts@^2.9.1:
version "2.9.1"
resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-2.9.1.tgz#953d3284851ffd097432379e271ce046a8180b37"
integrity sha512-2FAuSIGHlY+apM9FVlj8/oNhd+1y+Uwv5QNkMQz1oSfdHk4PXo1qoCw9I5M7j0vpH8CSWFJwXbVPeYDjLCx9PA==
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"