feat: message zaps
This commit is contained in:
parent
3224b03a98
commit
a48991c13e
@ -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"
|
||||||
},
|
},
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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)
|
switch (a.kind) {
|
||||||
.map((a) => {
|
case LIVE_STREAM_CHAT: {
|
||||||
switch (a.kind) {
|
return (
|
||||||
case 1311: {
|
<ChatMessage
|
||||||
return <ChatMessage ev={a} link={link} key={a.id} />;
|
streamer={link.author ?? ""}
|
||||||
}
|
ev={a}
|
||||||
case EventKind.ZapReceipt: {
|
link={link}
|
||||||
return <ChatZap ev={a} key={a.id} />;
|
key={a.id}
|
||||||
}
|
reactions={reactions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return null;
|
case EventKind.ZapReceipt: {
|
||||||
})}
|
return <ChatZap streamer={link.author ?? ""} ev={a} key={a.id} />;
|
||||||
{messages.data === undefined && <Spinner />}
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
{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" : ""}`}>
|
<>
|
||||||
<Profile pubkey={ev.pubkey} />
|
<div
|
||||||
<Text content={ev.content} tags={ev.tags} />
|
className={`message${link.author === ev.pubkey ? " streamer" : ""}`}
|
||||||
</div>
|
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} />
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
|
Loading…
Reference in New Issue
Block a user