diff --git a/package.json b/package.json index 09e172d..4451c07 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "version": "0.1.0", "private": true, "dependencies": { + "@emoji-mart/data": "^1.1.2", + "@emoji-mart/react": "^1.1.1", "@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4", "@react-hook/resize-observer": "^1.2.6", @@ -13,6 +15,7 @@ "@types/webscopeio__react-textarea-autocomplete": "^4.7.2", "@webscopeio/react-textarea-autocomplete": "^4.9.2", "buffer": "^6.0.3", + "emoji-mart": "^5.5.2", "hls.js": "^1.4.6", "lodash": "^4.17.21", "moment": "^2.29.4", @@ -22,6 +25,7 @@ "react-intersection-observer": "^9.5.1", "react-router-dom": "^6.13.0", "semantic-sdp": "^3.26.2", + "usehooks-ts": "^2.9.1", "web-vitals": "^2.1.0", "webrtc-adapter": "^8.2.3" }, diff --git a/src/element/live-chat.css b/src/element/live-chat.css index 681e023..1481a6f 100644 --- a/src/element/live-chat.css +++ b/src/element/live-chat.css @@ -92,6 +92,7 @@ .live-chat .message { word-wrap: break-word; + position: relative; } .live-chat .message .profile { @@ -191,7 +192,7 @@ position: relative; border-radius: 12px; border: 1px solid transparent; - background: black; + background: #0A0A0A; background-clip: padding-box; padding: 8px 12px; } @@ -217,3 +218,123 @@ .zap-content { margin-top: 8px; } + +.zap-pill { + border-radius: 100px; + background: rgba(255, 255, 255, 0.10); + width: fit-content; + display: flex; + height: 24px; + padding: 0px 4px; + align-items: center; + gap: 2px; +} + +.zap-pill-icon { + width: 12px; + height: 12px; + color: #FF8D2B; +} + +.message-zap-container { + display: flex; + padding: 8px; + justify-content: center; + align-items: flex-start; + gap: 12px; + border-radius: 12px; + border: 1px solid #303030; + background: #111; + box-shadow: 0px 7px 4px 0px rgba(0, 0, 0, 0.25); + margin-top: 4px; + width: fit-content; + z-index: 1; + transition: opacity .3s ease-out; +} + +@media (min-width: 1020px) { + .message-zap-container { + flex-direction: column; + } +} + +.message-zap-button { + border: none; + cursor: pointer; + height: 24px; + padding: 4px; + justify-content: center; + align-items: center; + gap: 2px; + border-radius: 100px; + background: rgba(255, 255, 255, 0.05); + color: #FFFFFF66; +} + +.message-zap-button:hover { + color: white; +} + +.message-zap-button-icon { + width: 16px; + height: 16px; +} + +.message-reactions { + display: flex; + align-items: flex-end; + gap: 4px; + margin-top: 4px; +} + +.message-reaction-container { + display: flex; + width: 24px; + height: 24px; + padding: 0px 4px; + justify-content: center; + align-items: center; + gap: 2px; + border-radius: 100px; + background: rgba(255, 255, 255, 0.10); +} + +.message-reaction { + font-size: 15px; + font-style: normal; + font-weight: 400; + line-height: 22px; +} + +.zap-pill-amount { + text-transform: lowercase; + color: #FFF; + font-size: 12px; + font-family: Outfit; + font-style: normal; + font-weight: 500; + line-height: 18px; +} + +.message-composer { + display: flex; + flex-direction: column; +} + +.write-message-container { + display: flex; + align-items: center; + gap: 8px; +} + +.write-message-container .paper { + flex: 1; +} + +.write-emoji-button { + color: #FFFFFF80; + cursor: pointer; +} +.write-emoji-button:hover { + color: white; +} diff --git a/src/element/live-chat.tsx b/src/element/live-chat.tsx index e5b8db9..f684b0e 100644 --- a/src/element/live-chat.tsx +++ b/src/element/live-chat.tsx @@ -10,11 +10,17 @@ import { import { useState, useEffect, + useMemo, + useRef, type KeyboardEvent, type ChangeEvent, + type RefObject, } from "react"; +import { useHover, useOnClickOutside, useMediaQuery } from "usehooks-ts"; -import useEmoji from "hooks/emoji"; +import data from "@emoji-mart/data"; +import Picker from "@emoji-mart/react"; +import useEmoji, { type EmojiPack } from "hooks/emoji"; import { System } from "index"; import { useLiveChatFeed } from "hooks/live-chat"; import AsyncButton from "./async-button"; @@ -23,10 +29,79 @@ import { Icon } from "./icon"; import { Text } from "./text"; import { Textarea } from "./textarea"; import Spinner from "./spinner"; +import { SendZapsDialog } from "./send-zap"; import { useLogin } from "hooks/login"; import { useUserProfile } from "@snort/system-react"; import { formatSats } from "number"; import useTopZappers from "hooks/top-zappers"; +import { LIVE_STREAM_CHAT } from "const"; +import { findTag } from "utils"; + +interface EmojiPickerProps { + topOffset: number; + leftOffset: number; + emojiPacks?: EmojiPack[]; + onEmojiSelect: (e: Emoji) => void; + onClickOutside: () => void; + height?: number; + ref: RefObject; +} + +function EmojiPicker({ + topOffset, + leftOffset, + onEmojiSelect, + onClickOutside, + emojiPacks = [], + height = 300, + ref, +}: EmojiPickerProps) { + const customEmojiList = emojiPacks.map((pack) => { + return { + id: pack.address, + name: pack.name, + emojis: pack.emojis.map((e) => { + const [, name, url] = e; + return { + id: name, + name, + skins: [{ src: url }], + }; + }), + }; + }); + return ( + <> +
+ + +
+ + ); +} export interface LiveChatOptions { canWrite?: boolean; @@ -67,13 +142,18 @@ export function LiveChat({ options?: LiveChatOptions; height?: number; }) { - const messages = useLiveChatFeed(link); + const feed = useLiveChatFeed(link); const login = useLogin(); - const events = messages.data ?? []; - const zaps = events + const zaps = feed.zaps .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 (
{(options?.showHeader ?? true) && ( @@ -85,20 +165,26 @@ export function LiveChat({
)}
- {[...(messages.data ?? [])] - .sort((a, b) => b.created_at - a.created_at) - .map((a) => { - switch (a.kind) { - case 1311: { - return ; - } - case EventKind.ZapReceipt: { - return ; - } + {events.map((a) => { + switch (a.kind) { + case LIVE_STREAM_CHAT: { + return ( + + ); } - return null; - })} - {messages.data === undefined && } + case EventKind.ZapReceipt: { + return ; + } + } + return null; + })} + {feed.messages.length === 0 && }
{(options?.canWrite ?? true) && (
@@ -113,16 +199,164 @@ export function LiveChat({ ); } -function ChatMessage({ ev, link }: { ev: TaggedRawEvent; link: NostrLink }) { +function emojifyReaction(reaction: string) { + if (reaction === "+") { + return "💜"; + } + if (reaction === "-") { + return "👎"; + } + return reaction; +} + +interface Emoji { + id: string; + native?: string; +} + +function ChatMessage({ + streamer, + ev, + link, + reactions, +}: { + streamer: string; + ev: TaggedRawEvent; + link: NostrLink; + reactions: readonly TaggedRawEvent[]; +}) { + const ref = useRef(null); + 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, ev.pubkey); + const zapTarget = profile?.lud16 ?? profile?.lud06; + const zaps = reactions + .filter((ev) => ev.kind === EventKind.ZapReceipt) + .map((ev) => parseZap(ev, System.ProfileLoader.Cache)) + .filter((z) => z && z.valid); + 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 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); + }, [reactions, ev]); + const hasZaps = totalZaps > 0; + + useOnClickOutside(ref, () => { + setShowZapDialog(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) {} + } + + // @ts-expect-error + const topOffset = ref.current?.getBoundingClientRect().top; + // @ts-expect-error + const leftOffset = ref.current?.getBoundingClientRect().left; + + function pickEmoji(ev: any) { + ev.stopPropagation(); + setShowEmojiPicker(!showEmojiPicker); + } + return ( -
- - -
+ <> +
setShowZapDialog(true)} + > + + + {(hasReactions || hasZaps) && ( +
+ {hasZaps && ( +
+ + {formatSats(totalZaps)} +
+ )} + {emojis.map((e) => ( +
+ {e} +
+ ))} +
+ )} + {ref.current && ( +
+ {zapTarget && ( + + + + } + targetName={profile?.name || ev.pubkey} + /> + )} + +
+ )} +
+ {showEmojiPicker && ( + setShowEmojiPicker(false)} + ref={emojiRef} + /> + )} + ); } -function ChatZap({ ev }: { ev: TaggedRawEvent }) { +function ChatZap({ streamer, ev }: { streamer: string; ev: TaggedRawEvent }) { const parsed = parseZap(ev, System.ProfileLoader.Cache); useUserProfile(System, parsed.anonZap ? undefined : parsed.sender); @@ -141,12 +375,13 @@ function ChatZap({ ev }: { ev: TaggedRawEvent }) { if (!parsed.valid) { return null; } - return ( + + return parsed.receiver === streamer ? (
{parsed.content &&
{parsed.content}
}
- ); + ) : null; } function WriteMessage({ link }: { link: NostrLink }) { + const ref = useRef(null); + const emojiRef = useRef(null); const [chat, setChat] = useState(""); + const [showEmojiPicker, setShowEmojiPicker] = useState(false); const login = useLogin(); - const userEmojis = useEmoji(login!.pubkey); - const channelEmojis = useEmoji(link.author!); + 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 names = emojis.map((t) => t.at(1)); + const allEmojiPacks = userEmojiPacks.concat(channelEmojiPacks); + // @ts-expect-error + const topOffset = ref.current?.getBoundingClientRect().top; + // @ts-expect-error + const leftOffset = ref.current?.getBoundingClientRect().left; async function sendChatMessage() { const pub = await EventPublisher.nip7(); @@ -184,7 +429,7 @@ function WriteMessage({ link }: { link: NostrLink }) { const emoji = [...emojiNames].map((name) => emojis.find((e) => e.at(1) === name) ); - eb.kind(1311 as EventKind) + eb.kind(LIVE_STREAM_CHAT as EventKind) .content(chat) .tag(["a", `${link.kind}:${link.author}:${link.id}`, "", "root"]) .processContent(); @@ -203,6 +448,15 @@ function WriteMessage({ link }: { link: NostrLink }) { } } + function onEmojiSelect(emoji: Emoji) { + if (emoji.native) { + setChat(`${chat}${emoji.native}`); + } else { + setChat(`${chat}:${emoji.id}:`); + } + setShowEmojiPicker(false); + } + async function onKeyDown(e: KeyboardEvent) { if (e.code === "Enter") { e.preventDefault(); @@ -215,15 +469,33 @@ function WriteMessage({ link }: { link: NostrLink }) { setChat(e.target.value); } + function pickEmoji(ev: any) { + ev.stopPropagation(); + setShowEmojiPicker(!showEmojiPicker); + } + return ( <> -
+