From 8211ab99f95342829f8709ed83be25b16e3a87bf Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Thu, 13 Jul 2023 13:42:20 +0200 Subject: [PATCH] feat: custom emoji reactions --- src/element/chat-message.tsx | 340 +++++++++++++++++++--------------- src/element/live-chat.css | 6 + src/element/live-chat.tsx | 12 +- src/element/write-message.tsx | 27 ++- src/hooks/emoji.tsx | 13 +- 5 files changed, 231 insertions(+), 167 deletions(-) diff --git a/src/element/chat-message.tsx b/src/element/chat-message.tsx index f30a162..698d3f5 100644 --- a/src/element/chat-message.tsx +++ b/src/element/chat-message.tsx @@ -1,180 +1,222 @@ import { useUserProfile } from "@snort/system-react"; import { NostrEvent, parseZap, EventPublisher, EventKind } from "@snort/system"; 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 { formatSats } from "../number"; import { EmojiPicker } from "./emoji-picker"; import { Icon } from "./icon"; +import { Emoji } from "./emoji"; import { Profile } from "./profile"; import { Text } from "./text"; import { SendZapsDialog } from "./send-zap"; import { findTag } from "../utils"; +import type { EmojiPack } from "../hooks/emoji"; interface Emoji { - id: string; - native?: string; + id: string; + native?: string; } function emojifyReaction(reaction: string) { - if (reaction === "+") { - return "💜"; - } - if (reaction === "-") { - return "👎"; - } - return reaction; + if (reaction === "+") { + return "💜"; + } + if (reaction === "-") { + return "👎"; + } + return reaction; } export function ChatMessage({ - streamer, - ev, - reactions, + streamer, + ev, + reactions, + emojiPacks, }: { - ev: NostrEvent; - streamer: string; - reactions: readonly NostrEvent[]; + ev: NostrEvent; + streamer: string; + reactions: readonly NostrEvent[]; + emojiPacks: EmojiPack[]; }) { - const ref = useRef(null); - const inView = useIntersectionObserver(ref, { - freezeOnceVisible: true - }) - const emojiRef = useRef(null); - const isTablet = useMediaQuery("(max-width: 1020px)"); - const isHovering = useHover(ref); - const [showZapDialog, setShowZapDialog] = useState(false); - const [showEmojiPicker, setShowEmojiPicker] = useState(false); - const profile = useUserProfile(System, inView?.isIntersecting ? ev.pubkey : undefined); - const zapTarget = profile?.lud16 ?? profile?.lud06; - const zaps = useMemo(() => { - return reactions.filter(a => a.kind === EventKind.ZapReceipt) - .map(a => parseZap(a, System.ProfileLoader.Cache)) - .filter(a => a && a.valid); - }, [reactions]) - const emojis = useMemo(() => { - 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 ref = useRef(null); + const inView = useIntersectionObserver(ref, { + freezeOnceVisible: true, + }); + const emojiRef = useRef(null); + const isTablet = useMediaQuery("(max-width: 1020px)"); + const isHovering = useHover(ref); + const [showZapDialog, setShowZapDialog] = useState(false); + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + const profile = useUserProfile( + System, + inView?.isIntersecting ? ev.pubkey : undefined + ); + const zapTarget = profile?.lud16 ?? profile?.lud06; + const zaps = useMemo(() => { + return reactions + .filter((a) => a.kind === EventKind.ZapReceipt) + .map((a) => parseZap(a, System.ProfileLoader.Cache)) + .filter((a) => a && a.valid); + }, [reactions]); + const emojiReactions = useMemo(() => { + 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 totalZaps = useMemo(() => { - const messageZaps = zaps.filter((z) => z.event === ev.id); - return messageZaps.reduce((acc, z) => acc + z.amount, 0); - }, [zaps, ev]); - const hasZaps = totalZaps > 0; + const hasReactions = emojiReactions.length > 0; + const totalZaps = useMemo(() => { + const messageZaps = zaps.filter((z) => z.event === ev.id); + return messageZaps.reduce((acc, z) => acc + z.amount, 0); + }, [zaps, ev]); + const hasZaps = totalZaps > 0; - useOnClickOutside(ref, () => { - setShowZapDialog(false); - }); + useOnClickOutside(ref, () => { + setShowZapDialog(false); + }); - useOnClickOutside(emojiRef, () => { - setShowEmojiPicker(false); - }); + useOnClickOutside(emojiRef, () => { + setShowEmojiPicker(false); + }); - async function onEmojiSelect(emoji: Emoji) { - setShowEmojiPicker(false); - 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) { } - } + function getEmojiById(id: string) { + return emojiNames.find((e) => e.at(1) === id); + } - // @ts-expect-error - const topOffset = ref.current?.getBoundingClientRect().top; - // @ts-expect-error - const leftOffset = ref.current?.getBoundingClientRect().left; + async function onEmojiSelect(emoji: Emoji) { + setShowEmojiPicker(false); + setShowZapDialog(false); + 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) { - ev.stopPropagation(); - setShowEmojiPicker(!showEmojiPicker); - } + // @ts-expect-error + const topOffset = ref.current?.getBoundingClientRect().top; + // @ts-expect-error + const leftOffset = ref.current?.getBoundingClientRect().left; - return ( - <> -
setShowZapDialog(true)} - > - - ) - } - pubkey={ev.pubkey} - profile={profile} - /> - - {(hasReactions || hasZaps) && ( -
- {hasZaps && ( -
- - {formatSats(totalZaps)} -
- )} - {emojis.map((e) => ( -
- {e} -
- ))} -
- )} - {ref.current && ( -
- {zapTarget && ( - - - - } - targetName={profile?.name || ev.pubkey} - /> - )} - -
- )} -
- {showEmojiPicker && ( - setShowEmojiPicker(false)} - ref={emojiRef} - /> + function pickEmoji(ev: any) { + ev.stopPropagation(); + setShowEmojiPicker(!showEmojiPicker); + } + + return ( + <> +
setShowZapDialog(true)} + > + } + pubkey={ev.pubkey} + profile={profile} + /> + + {(hasReactions || hasZaps) && ( +
+ {hasZaps && ( +
+ + {formatSats(totalZaps)} +
)} - - ); + {emojiReactions.map((e) => { + const isCustomEmojiReaction = + e.length > 1 && e.startsWith(":") && e.endsWith(":"); + const emojiName = e.replace(/:/g, ""); + const emoji = isCustomEmojiReaction && getEmojiById(emojiName); + return ( +
+ {isCustomEmojiReaction && emoji ? ( + + + + ) : ( + {e} + )} +
+ ); + })} +
+ )} + {ref.current && ( +
+ {zapTarget && ( + + + + } + targetName={profile?.name || ev.pubkey} + /> + )} + +
+ )} +
+ {showEmojiPicker && ( + setShowEmojiPicker(false)} + ref={emojiRef} + /> + )} + + ); } diff --git a/src/element/live-chat.css b/src/element/live-chat.css index 770dbd0..ab4d52d 100644 --- a/src/element/live-chat.css +++ b/src/element/live-chat.css @@ -347,6 +347,12 @@ line-height: 22px; } +.message-reaction .emoji { + width: 15px; + height: 15px; + margin-bottom: -2px; +} + .zap-pill-amount { text-transform: lowercase; color: #FFF; diff --git a/src/element/live-chat.tsx b/src/element/live-chat.tsx index 213ca1a..4edefc0 100644 --- a/src/element/live-chat.tsx +++ b/src/element/live-chat.tsx @@ -9,8 +9,10 @@ import { encodeTLV, } from "@snort/system"; import { useEffect, useMemo } from "react"; +import uniqBy from "lodash.uniqby"; import { System } from "../index"; +import useEmoji, { packId } from "../hooks/emoji"; import { useLiveChatFeed } from "../hooks/live-chat"; import { Profile } from "./profile"; import { Icon } from "./icon"; @@ -76,6 +78,13 @@ export function LiveChat({ return () => System.ProfileLoader.UntrackMetadata(pubkeys); }, [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 .map((ev) => parseZap(ev, System.ProfileLoader.Cache)) .filter((z) => z && z.valid); @@ -137,6 +146,7 @@ export function LiveChat({ case LIVE_STREAM_CHAT: { return ( {login ? ( - + ) : (

Please login to write messages!

)} diff --git a/src/element/write-message.tsx b/src/element/write-message.tsx index f29d84c..9d3d763 100644 --- a/src/element/write-message.tsx +++ b/src/element/write-message.tsx @@ -1,6 +1,5 @@ import { NostrLink, EventPublisher, EventKind } from "@snort/system"; import { useRef, useState, useMemo, ChangeEvent } from "react"; -import uniqBy from "lodash.uniqby"; import { LIVE_STREAM_CHAT } from "../const"; import useEmoji, { packId } from "../hooks/emoji"; @@ -10,27 +9,23 @@ import AsyncButton from "./async-button"; import { Icon } from "./icon"; import { Textarea } from "./textarea"; import { EmojiPicker } from "./emoji-picker"; +import type { EmojiPack, Emoji } from "../hooks/emoji"; -interface Emoji { - id: string; - native?: string; -} - -export function WriteMessage({ link }: { link: NostrLink }) { +export function WriteMessage({ + link, + emojiPacks, +}: { + link: NostrLink; + emojiPacks: EmojiPack[]; +}) { const ref = useRef(null); const emojiRef = useRef(null); const [chat, setChat] = useState(""); const [showEmojiPicker, setShowEmojiPicker] = useState(false); const login = useLogin(); - const userEmojiPacks = useEmoji(login!.pubkey); - 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 emojis = emojiPacks.map((pack) => pack.emojis).flat(); const names = emojis.map((t) => t.at(1)); - const allEmojiPacks = useMemo(() => { - return uniqBy(channelEmojiPacks.concat(userEmojiPacks), packId); - }, [userEmojiPacks, channelEmojiPacks]); + // @ts-expect-error const topOffset = ref.current?.getBoundingClientRect().top; // @ts-expect-error @@ -112,7 +107,7 @@ export function WriteMessage({ link }: { link: NostrLink }) { setShowEmojiPicker(false)} ref={emojiRef} diff --git a/src/hooks/emoji.tsx b/src/hooks/emoji.tsx index 4b881cb..479581a 100644 --- a/src/hooks/emoji.tsx +++ b/src/hooks/emoji.tsx @@ -12,6 +12,11 @@ import { findTag } from "utils"; import type { EmojiTag } from "../element/emoji"; import uniqBy from "lodash.uniqby"; +export interface Emoji { + native?: string; + id?: string; +} + export interface EmojiPack { address: string; name: string; @@ -19,13 +24,19 @@ export interface EmojiPack { emojis: EmojiTag[]; } +function cleanShortcode(shortcode?: string) { + return shortcode?.replace(/\s+/, "_"); +} + function toEmojiPack(ev: NostrEvent): EmojiPack { const d = findTag(ev, "d") || ""; return { address: `${ev.kind}:${ev.pubkey}:${d}`, name: d, 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[], }; }