feat: custom emoji reactions

This commit is contained in:
Alejandro Gomez
2023-07-13 13:42:20 +02:00
parent f5f2df5eba
commit 8211ab99f9
5 changed files with 231 additions and 167 deletions

View File

@ -1,180 +1,222 @@
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import { NostrEvent, parseZap, EventPublisher, EventKind } from "@snort/system"; import { NostrEvent, parseZap, EventPublisher, EventKind } from "@snort/system";
import { useRef, useState, useMemo } from "react"; import { useRef, useState, useMemo } from "react";
import { useMediaQuery, useHover, useOnClickOutside, useIntersectionObserver } from "usehooks-ts"; import {
useMediaQuery,
useHover,
useOnClickOutside,
useIntersectionObserver,
} from "usehooks-ts";
import { System } from "../index"; import { System } from "../index";
import { formatSats } from "../number"; import { formatSats } from "../number";
import { EmojiPicker } from "./emoji-picker"; import { EmojiPicker } from "./emoji-picker";
import { Icon } from "./icon"; import { Icon } from "./icon";
import { Emoji } from "./emoji";
import { Profile } from "./profile"; import { Profile } from "./profile";
import { Text } from "./text"; import { Text } from "./text";
import { SendZapsDialog } from "./send-zap"; import { SendZapsDialog } from "./send-zap";
import { findTag } from "../utils"; import { findTag } from "../utils";
import type { EmojiPack } from "../hooks/emoji";
interface Emoji { interface Emoji {
id: string; id: string;
native?: string; native?: string;
} }
function emojifyReaction(reaction: string) { function emojifyReaction(reaction: string) {
if (reaction === "+") { if (reaction === "+") {
return "💜"; return "💜";
} }
if (reaction === "-") { if (reaction === "-") {
return "👎"; return "👎";
} }
return reaction; return reaction;
} }
export function ChatMessage({ export function ChatMessage({
streamer, streamer,
ev, ev,
reactions, reactions,
emojiPacks,
}: { }: {
ev: NostrEvent; ev: NostrEvent;
streamer: string; streamer: string;
reactions: readonly NostrEvent[]; reactions: readonly NostrEvent[];
emojiPacks: EmojiPack[];
}) { }) {
const ref = useRef(null); const ref = useRef(null);
const inView = useIntersectionObserver(ref, { const inView = useIntersectionObserver(ref, {
freezeOnceVisible: true freezeOnceVisible: true,
}) });
const emojiRef = useRef(null); const emojiRef = useRef(null);
const isTablet = useMediaQuery("(max-width: 1020px)"); const isTablet = useMediaQuery("(max-width: 1020px)");
const isHovering = useHover(ref); const isHovering = useHover(ref);
const [showZapDialog, setShowZapDialog] = useState(false); const [showZapDialog, setShowZapDialog] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const profile = useUserProfile(System, inView?.isIntersecting ? ev.pubkey : undefined); const profile = useUserProfile(
const zapTarget = profile?.lud16 ?? profile?.lud06; System,
const zaps = useMemo(() => { inView?.isIntersecting ? ev.pubkey : undefined
return reactions.filter(a => a.kind === EventKind.ZapReceipt) );
.map(a => parseZap(a, System.ProfileLoader.Cache)) const zapTarget = profile?.lud16 ?? profile?.lud06;
.filter(a => a && a.valid); const zaps = useMemo(() => {
}, [reactions]) return reactions
const emojis = useMemo(() => { .filter((a) => a.kind === EventKind.ZapReceipt)
const emojified = reactions .map((a) => parseZap(a, System.ProfileLoader.Cache))
.filter((e) => e.kind === EventKind.Reaction && findTag(e, "e") === ev.id) .filter((a) => a && a.valid);
.map((ev) => emojifyReaction(ev.content)); }, [reactions]);
return [...new Set(emojified)]; const emojiReactions = useMemo(() => {
}, [ev, reactions]); const emojified = reactions
.filter((e) => e.kind === EventKind.Reaction && findTag(e, "e") === ev.id)
.map((ev) => emojifyReaction(ev.content));
return [...new Set(emojified)];
}, [ev, reactions]);
const emojiNames = emojiPacks.map((p) => p.emojis).flat();
const hasReactions = emojis.length > 0; const hasReactions = emojiReactions.length > 0;
const totalZaps = useMemo(() => { const totalZaps = useMemo(() => {
const messageZaps = zaps.filter((z) => z.event === ev.id); const messageZaps = zaps.filter((z) => z.event === ev.id);
return messageZaps.reduce((acc, z) => acc + z.amount, 0); return messageZaps.reduce((acc, z) => acc + z.amount, 0);
}, [zaps, ev]); }, [zaps, ev]);
const hasZaps = totalZaps > 0; const hasZaps = totalZaps > 0;
useOnClickOutside(ref, () => { useOnClickOutside(ref, () => {
setShowZapDialog(false); setShowZapDialog(false);
}); });
useOnClickOutside(emojiRef, () => { useOnClickOutside(emojiRef, () => {
setShowEmojiPicker(false); setShowEmojiPicker(false);
}); });
async function onEmojiSelect(emoji: Emoji) { function getEmojiById(id: string) {
setShowEmojiPicker(false); return emojiNames.find((e) => e.at(1) === id);
setShowZapDialog(false); }
try {
const pub = await EventPublisher.nip7();
const reply = await pub?.react(ev, emoji.native || "+1");
if (reply) {
console.debug(reply);
System.BroadcastEvent(reply);
}
} catch (error) { }
}
// @ts-expect-error async function onEmojiSelect(emoji: Emoji) {
const topOffset = ref.current?.getBoundingClientRect().top; setShowEmojiPicker(false);
// @ts-expect-error setShowZapDialog(false);
const leftOffset = ref.current?.getBoundingClientRect().left; let reply = null;
try {
const pub = await EventPublisher.nip7();
if (emoji.native) {
reply = await pub?.react(ev, emoji.native || "+1");
} else {
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.at(1)!, e.at(2)!]);
});
}
}
if (reply) {
console.debug(reply);
System.BroadcastEvent(reply);
}
} catch (error) {}
}
function pickEmoji(ev: any) { // @ts-expect-error
ev.stopPropagation(); const topOffset = ref.current?.getBoundingClientRect().top;
setShowEmojiPicker(!showEmojiPicker); // @ts-expect-error
} const leftOffset = ref.current?.getBoundingClientRect().left;
return ( function pickEmoji(ev: any) {
<> ev.stopPropagation();
<div setShowEmojiPicker(!showEmojiPicker);
className={`message${streamer === ev.pubkey ? " streamer" : ""}`} }
ref={ref}
onClick={() => setShowZapDialog(true)} return (
> <>
<Profile <div
icon={ className={`message${streamer === ev.pubkey ? " streamer" : ""}`}
ev.pubkey === streamer && ( ref={ref}
<Icon name="signal" size={16} /> onClick={() => setShowZapDialog(true)}
) >
} <Profile
pubkey={ev.pubkey} icon={ev.pubkey === streamer && <Icon name="signal" size={16} />}
profile={profile} pubkey={ev.pubkey}
/> profile={profile}
<Text content={ev.content} tags={ev.tags} /> />
{(hasReactions || hasZaps) && ( <Text content={ev.content} tags={ev.tags} />
<div className="message-reactions"> {(hasReactions || hasZaps) && (
{hasZaps && ( <div className="message-reactions">
<div className="zap-pill"> {hasZaps && (
<Icon name="zap-filled" className="zap-pill-icon" /> <div className="zap-pill">
<span className="zap-pill-amount">{formatSats(totalZaps)}</span> <Icon name="zap-filled" className="zap-pill-icon" />
</div> <span className="zap-pill-amount">{formatSats(totalZaps)}</span>
)} </div>
{emojis.map((e) => (
<div className="message-reaction-container">
<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 - 12,
left: leftOffset - 32,
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>
</div>
)}
</div>
{showEmojiPicker && (
<EmojiPicker
topOffset={topOffset}
leftOffset={leftOffset}
onEmojiSelect={onEmojiSelect}
onClickOutside={() => setShowEmojiPicker(false)}
ref={emojiRef}
/>
)} )}
</> {emojiReactions.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">
{isCustomEmojiReaction && emoji ? (
<span className="message-reaction">
<Emoji name={emoji.at(1)!} url={emoji.at(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 - 12,
left: leftOffset - 32,
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>
</div>
)}
</div>
{showEmojiPicker && (
<EmojiPicker
topOffset={topOffset}
leftOffset={leftOffset}
emojiPacks={emojiPacks}
onEmojiSelect={onEmojiSelect}
onClickOutside={() => setShowEmojiPicker(false)}
ref={emojiRef}
/>
)}
</>
);
} }

View File

@ -347,6 +347,12 @@
line-height: 22px; line-height: 22px;
} }
.message-reaction .emoji {
width: 15px;
height: 15px;
margin-bottom: -2px;
}
.zap-pill-amount { .zap-pill-amount {
text-transform: lowercase; text-transform: lowercase;
color: #FFF; color: #FFF;

View File

@ -9,8 +9,10 @@ import {
encodeTLV, encodeTLV,
} from "@snort/system"; } from "@snort/system";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import uniqBy from "lodash.uniqby";
import { System } from "../index"; import { System } from "../index";
import useEmoji, { packId } from "../hooks/emoji";
import { useLiveChatFeed } from "../hooks/live-chat"; import { useLiveChatFeed } from "../hooks/live-chat";
import { Profile } from "./profile"; import { Profile } from "./profile";
import { Icon } from "./icon"; import { Icon } from "./icon";
@ -76,6 +78,13 @@ export function LiveChat({
return () => System.ProfileLoader.UntrackMetadata(pubkeys); return () => System.ProfileLoader.UntrackMetadata(pubkeys);
}, [feed.zaps]); }, [feed.zaps]);
const userEmojiPacks = useEmoji(login!.pubkey);
const userEmojis = userEmojiPacks.map((pack) => pack.emojis).flat();
const channelEmojiPacks = useEmoji(host);
const allEmojiPacks = useMemo(() => {
return uniqBy(channelEmojiPacks.concat(userEmojiPacks), packId);
}, [userEmojiPacks, channelEmojiPacks]);
const zaps = feed.zaps const zaps = feed.zaps
.map((ev) => parseZap(ev, System.ProfileLoader.Cache)) .map((ev) => parseZap(ev, System.ProfileLoader.Cache))
.filter((z) => z && z.valid); .filter((z) => z && z.valid);
@ -137,6 +146,7 @@ export function LiveChat({
case LIVE_STREAM_CHAT: { case LIVE_STREAM_CHAT: {
return ( return (
<ChatMessage <ChatMessage
emojiPacks={allEmojiPacks}
streamer={streamer} streamer={streamer}
ev={a} ev={a}
key={a.id} key={a.id}
@ -160,7 +170,7 @@ export function LiveChat({
{(options?.canWrite ?? true) && ( {(options?.canWrite ?? true) && (
<div className="write-message"> <div className="write-message">
{login ? ( {login ? (
<WriteMessage link={link} /> <WriteMessage emojiPacks={allEmojiPacks} link={link} />
) : ( ) : (
<p>Please login to write messages!</p> <p>Please login to write messages!</p>
)} )}

View File

@ -1,6 +1,5 @@
import { NostrLink, EventPublisher, EventKind } from "@snort/system"; import { NostrLink, EventPublisher, EventKind } from "@snort/system";
import { useRef, useState, useMemo, ChangeEvent } from "react"; import { useRef, useState, useMemo, ChangeEvent } from "react";
import uniqBy from "lodash.uniqby";
import { LIVE_STREAM_CHAT } from "../const"; import { LIVE_STREAM_CHAT } from "../const";
import useEmoji, { packId } from "../hooks/emoji"; import useEmoji, { packId } from "../hooks/emoji";
@ -10,27 +9,23 @@ import AsyncButton from "./async-button";
import { Icon } from "./icon"; import { Icon } from "./icon";
import { Textarea } from "./textarea"; import { Textarea } from "./textarea";
import { EmojiPicker } from "./emoji-picker"; import { EmojiPicker } from "./emoji-picker";
import type { EmojiPack, Emoji } from "../hooks/emoji";
interface Emoji { export function WriteMessage({
id: string; link,
native?: string; emojiPacks,
} }: {
link: NostrLink;
export function WriteMessage({ link }: { link: NostrLink }) { emojiPacks: EmojiPack[];
}) {
const ref = useRef(null); const ref = useRef(null);
const emojiRef = useRef(null); const emojiRef = useRef(null);
const [chat, setChat] = useState(""); const [chat, setChat] = useState("");
const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const login = useLogin(); const login = useLogin();
const userEmojiPacks = useEmoji(login!.pubkey); const emojis = emojiPacks.map((pack) => pack.emojis).flat();
const userEmojis = userEmojiPacks.map((pack) => pack.emojis).flat();
const channelEmojiPacks = useEmoji(link.author!);
const channelEmojis = channelEmojiPacks.map((pack) => pack.emojis).flat();
const emojis = userEmojis.concat(channelEmojis);
const names = emojis.map((t) => t.at(1)); const names = emojis.map((t) => t.at(1));
const allEmojiPacks = useMemo(() => {
return uniqBy(channelEmojiPacks.concat(userEmojiPacks), packId);
}, [userEmojiPacks, channelEmojiPacks]);
// @ts-expect-error // @ts-expect-error
const topOffset = ref.current?.getBoundingClientRect().top; const topOffset = ref.current?.getBoundingClientRect().top;
// @ts-expect-error // @ts-expect-error
@ -112,7 +107,7 @@ export function WriteMessage({ link }: { link: NostrLink }) {
<EmojiPicker <EmojiPicker
topOffset={topOffset} topOffset={topOffset}
leftOffset={leftOffset} leftOffset={leftOffset}
emojiPacks={allEmojiPacks} emojiPacks={emojiPacks}
onEmojiSelect={onEmojiSelect} onEmojiSelect={onEmojiSelect}
onClickOutside={() => setShowEmojiPicker(false)} onClickOutside={() => setShowEmojiPicker(false)}
ref={emojiRef} ref={emojiRef}

View File

@ -12,6 +12,11 @@ import { findTag } from "utils";
import type { EmojiTag } from "../element/emoji"; import type { EmojiTag } from "../element/emoji";
import uniqBy from "lodash.uniqby"; import uniqBy from "lodash.uniqby";
export interface Emoji {
native?: string;
id?: string;
}
export interface EmojiPack { export interface EmojiPack {
address: string; address: string;
name: string; name: string;
@ -19,13 +24,19 @@ export interface EmojiPack {
emojis: EmojiTag[]; emojis: EmojiTag[];
} }
function cleanShortcode(shortcode?: string) {
return shortcode?.replace(/\s+/, "_");
}
function toEmojiPack(ev: NostrEvent): EmojiPack { function toEmojiPack(ev: NostrEvent): EmojiPack {
const d = findTag(ev, "d") || ""; const d = findTag(ev, "d") || "";
return { return {
address: `${ev.kind}:${ev.pubkey}:${d}`, address: `${ev.kind}:${ev.pubkey}:${d}`,
name: d, name: d,
author: ev.pubkey, author: ev.pubkey,
emojis: ev.tags.filter((t) => t.at(0) === "emoji") as EmojiTag[], emojis: ev.tags
.filter((t) => t.at(0) === "emoji")
.map((t) => ["emoji", cleanShortcode(t.at(1)), t.at(2)]) as EmojiTag[],
}; };
} }