228 lines
7.4 KiB
TypeScript
228 lines
7.4 KiB
TypeScript
import { SnortContext, useEventReactions, useUserProfile } from "@snort/system-react";
|
|
import { EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
|
|
import React, { Suspense, lazy, useContext, useMemo, useRef, useState } from "react";
|
|
import { useHover, useIntersectionObserver, useMediaQuery, useOnClickOutside } from "usehooks-ts";
|
|
import { dedupe } from "@snort/shared";
|
|
|
|
const EmojiPicker = lazy(() => import("./emoji-picker"));
|
|
import { Icon } from "./icon";
|
|
import { Emoji as EmojiComponent } from "./emoji";
|
|
import { Profile } from "./profile";
|
|
import { Text } from "./text";
|
|
import { useMute } from "./mute-button";
|
|
import { SendZapsDialog } from "./send-zap";
|
|
import { CollapsibleEvent } from "./collapsible";
|
|
|
|
import { useLogin } from "@/hooks/login";
|
|
import { formatSats } from "@/number";
|
|
import type { Badge, Emoji, EmojiPack } from "@/types";
|
|
|
|
function emojifyReaction(reaction: string) {
|
|
if (reaction === "+") {
|
|
return "💜";
|
|
}
|
|
if (reaction === "-") {
|
|
return "👎";
|
|
}
|
|
return reaction;
|
|
}
|
|
|
|
export function ChatMessage({
|
|
streamer,
|
|
ev,
|
|
related,
|
|
emojiPacks,
|
|
badges,
|
|
}: {
|
|
ev: TaggedNostrEvent;
|
|
streamer: string;
|
|
related: ReadonlyArray<TaggedNostrEvent>;
|
|
emojiPacks: EmojiPack[];
|
|
badges: Badge[];
|
|
}) {
|
|
const system = useContext(SnortContext);
|
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
const inView = useIntersectionObserver(ref, {
|
|
freezeOnceVisible: true,
|
|
});
|
|
const emojiRef = useRef(null);
|
|
const link = NostrLink.fromEvent(ev);
|
|
const isTablet = useMediaQuery("(max-width: 1020px)");
|
|
const isHovering = useHover(ref);
|
|
const { mute } = useMute(ev.pubkey);
|
|
const [showZapDialog, setShowZapDialog] = useState(false);
|
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
|
const login = useLogin();
|
|
const profile = useUserProfile(inView?.isIntersecting ? ev.pubkey : undefined);
|
|
const shouldShowMuteButton = ev.pubkey !== streamer && ev.pubkey !== login?.pubkey;
|
|
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
|
const { zaps, reactions } = useEventReactions(link, related);
|
|
const emojiNames = emojiPacks.map(p => p.emojis).flat();
|
|
|
|
const filteredReactions = useMemo(() => {
|
|
return reactions.all.filter(a => link.isReplyToThis(a));
|
|
}, [ev, reactions.all]);
|
|
|
|
const hasReactions = filteredReactions.length > 0;
|
|
const totalZaps = useMemo(() => {
|
|
return zaps.filter(a => a.event?.id === ev.id).reduce((acc, z) => acc + z.amount, 0);
|
|
}, [zaps, ev]);
|
|
const hasZaps = totalZaps > 0;
|
|
const awardedBadges = badges.filter(b => b.awardees.has(ev.pubkey) && b.accepted.has(ev.pubkey));
|
|
|
|
useOnClickOutside(ref, () => {
|
|
setShowZapDialog(false);
|
|
});
|
|
|
|
useOnClickOutside(emojiRef, () => {
|
|
setShowEmojiPicker(false);
|
|
});
|
|
|
|
function getEmojiById(id: string) {
|
|
return emojiNames.find(e => e.at(1) === id);
|
|
}
|
|
|
|
async function onEmojiSelect(emoji: Emoji) {
|
|
setShowEmojiPicker(false);
|
|
setShowZapDialog(false);
|
|
let reply = null;
|
|
try {
|
|
const pub = login?.publisher();
|
|
if (emoji.native) {
|
|
reply = await pub?.react(ev, emoji.native || "+1");
|
|
} else if (emoji.id) {
|
|
const e = getEmojiById(emoji.id);
|
|
if (e) {
|
|
reply = await pub?.generic(eb => {
|
|
return eb
|
|
.kind(EventKind.Reaction)
|
|
.content(`:${emoji.id}:`)
|
|
.tag(["e", ev.id])
|
|
.tag(["p", ev.pubkey])
|
|
.tag(["emoji", e[1], e[2]]);
|
|
});
|
|
}
|
|
}
|
|
if (reply) {
|
|
console.debug(reply);
|
|
system.BroadcastEvent(reply);
|
|
}
|
|
} catch {
|
|
//ignore
|
|
}
|
|
}
|
|
|
|
const topOffset = ref.current?.getBoundingClientRect().top;
|
|
const leftOffset = ref.current?.getBoundingClientRect().left;
|
|
|
|
function pickEmoji(e: React.MouseEvent) {
|
|
e.stopPropagation();
|
|
setShowEmojiPicker(!showEmojiPicker);
|
|
}
|
|
|
|
function muteUser(e: React.MouseEvent) {
|
|
e.stopPropagation();
|
|
mute();
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className={`message${streamer === ev.pubkey ? " streamer" : ""}`} ref={ref}>
|
|
<Profile
|
|
icon={
|
|
ev.pubkey === streamer ? (
|
|
<Icon name="signal" size={16} />
|
|
) : (
|
|
awardedBadges.map(badge => {
|
|
return (
|
|
<img key={badge.name} className="badge-icon" src={badge.thumb || badge.image} alt={badge.name} />
|
|
);
|
|
})
|
|
)
|
|
}
|
|
pubkey={ev.pubkey}
|
|
profile={profile}
|
|
/>
|
|
<Text tags={ev.tags} content={ev.content} eventComponent={CollapsibleEvent} />
|
|
{(hasReactions || hasZaps) && (
|
|
<div className="message-reactions">
|
|
{hasZaps && (
|
|
<div className="zap-pill">
|
|
<Icon name="zap-filled" className="zap-pill-icon" />
|
|
<span className="zap-pill-amount">{formatSats(totalZaps)}</span>
|
|
</div>
|
|
)}
|
|
{dedupe(filteredReactions.map(v => emojifyReaction(v.content))).map(e => {
|
|
const isCustomEmojiReaction = e.length > 1 && e.startsWith(":") && e.endsWith(":");
|
|
const emojiName = e.replace(/:/g, "");
|
|
const emoji = isCustomEmojiReaction && getEmojiById(emojiName);
|
|
return (
|
|
<div className="message-reaction-container" key={`${ev.id}-${emojiName}`}>
|
|
{isCustomEmojiReaction && emoji ? (
|
|
<span className="message-reaction">
|
|
<EmojiComponent name={emoji[1]} url={emoji[2]} />
|
|
</span>
|
|
) : (
|
|
<span className="message-reaction">{e}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{ref.current && (
|
|
<div
|
|
className="message-zap-container"
|
|
style={
|
|
isTablet
|
|
? {
|
|
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",
|
|
}
|
|
}>
|
|
{zapTarget && (
|
|
<SendZapsDialog
|
|
lnurl={zapTarget}
|
|
eTag={ev.id}
|
|
pubkey={ev.pubkey}
|
|
button={
|
|
<button className="message-zap-button">
|
|
<Icon name="zap" className="message-zap-button-icon" />
|
|
</button>
|
|
}
|
|
targetName={profile?.name || ev.pubkey}
|
|
/>
|
|
)}
|
|
<button className="message-zap-button" onClick={pickEmoji}>
|
|
<Icon name="face" className="message-zap-button-icon" />
|
|
</button>
|
|
{shouldShowMuteButton && (
|
|
<button className="message-zap-button" onClick={muteUser}>
|
|
<Icon name="user-x" className="message-zap-button-icon" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{showEmojiPicker && (
|
|
<Suspense>
|
|
<EmojiPicker
|
|
topOffset={topOffset ?? 0}
|
|
leftOffset={leftOffset ?? 0}
|
|
emojiPacks={emojiPacks}
|
|
onEmojiSelect={onEmojiSelect}
|
|
onClickOutside={() => setShowEmojiPicker(false)}
|
|
ref={emojiRef}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
</>
|
|
);
|
|
}
|