feat: custom emoji autocomplete
This commit is contained in:
@ -262,7 +262,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
padding: 0px 4px;
|
padding: 4px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@ -315,3 +315,26 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 18px;
|
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;
|
||||||
|
}
|
||||||
|
@ -14,13 +14,13 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
type LegacyRef,
|
type RefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useHover, useOnClickOutside, useMediaQuery } from "usehooks-ts";
|
import { useHover, useOnClickOutside, useMediaQuery } from "usehooks-ts";
|
||||||
|
|
||||||
import data from "@emoji-mart/data";
|
import data from "@emoji-mart/data";
|
||||||
import Picker from "@emoji-mart/react";
|
import Picker from "@emoji-mart/react";
|
||||||
import useEmoji from "hooks/emoji";
|
import useEmoji, { type EmojiPack } from "hooks/emoji";
|
||||||
import { System } from "index";
|
import { System } from "index";
|
||||||
import { useLiveChatFeed } from "hooks/live-chat";
|
import { useLiveChatFeed } from "hooks/live-chat";
|
||||||
import AsyncButton from "./async-button";
|
import AsyncButton from "./async-button";
|
||||||
@ -37,6 +37,71 @@ import useTopZappers from "hooks/top-zappers";
|
|||||||
import { LIVE_STREAM_CHAT } from "const";
|
import { LIVE_STREAM_CHAT } from "const";
|
||||||
import { findTag } from "utils";
|
import { findTag } from "utils";
|
||||||
|
|
||||||
|
interface EmojiPickerProps {
|
||||||
|
topOffset: number;
|
||||||
|
leftOffset: number;
|
||||||
|
emojiPacks?: EmojiPack[];
|
||||||
|
onEmojiSelect: (e: Emoji) => void;
|
||||||
|
onClickOutside: () => void;
|
||||||
|
height?: number;
|
||||||
|
ref: RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: topOffset - height - 10,
|
||||||
|
left: leftOffset,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
em-emoji-picker { max-height: ${height}px; }
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
<Picker
|
||||||
|
data={data}
|
||||||
|
custom={customEmojiList}
|
||||||
|
perLine={7}
|
||||||
|
previewPosition="none"
|
||||||
|
skinTonePosition="search"
|
||||||
|
theme="dark"
|
||||||
|
onEmojiSelect={onEmojiSelect}
|
||||||
|
onClickOutside={onClickOutside}
|
||||||
|
maxFrequentRows={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface LiveChatOptions {
|
export interface LiveChatOptions {
|
||||||
canWrite?: boolean;
|
canWrite?: boolean;
|
||||||
showHeader?: boolean;
|
showHeader?: boolean;
|
||||||
@ -144,7 +209,8 @@ function emojifyReaction(reaction: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Emoji {
|
interface Emoji {
|
||||||
native: string;
|
id: string;
|
||||||
|
native?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatMessage({
|
function ChatMessage({
|
||||||
@ -198,7 +264,7 @@ function ChatMessage({
|
|||||||
const pub = await EventPublisher.nip7();
|
const pub = await EventPublisher.nip7();
|
||||||
const reply = await pub?.generic((eb) => {
|
const reply = await pub?.generic((eb) => {
|
||||||
eb.kind(EventKind.Reaction)
|
eb.kind(EventKind.Reaction)
|
||||||
.content(emoji.native)
|
.content(emoji.native || "+1")
|
||||||
.tag(["e", ev.id])
|
.tag(["e", ev.id])
|
||||||
.tag(["p", ev.pubkey]);
|
.tag(["p", ev.pubkey]);
|
||||||
return eb;
|
return eb;
|
||||||
@ -215,6 +281,11 @@ function ChatMessage({
|
|||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
const leftOffset = ref.current?.getBoundingClientRect().left;
|
const leftOffset = ref.current?.getBoundingClientRect().left;
|
||||||
|
|
||||||
|
function pickEmoji(ev: any) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
setShowEmojiPicker(!showEmojiPicker);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@ -274,39 +345,20 @@ function ChatMessage({
|
|||||||
targetName={profile?.name || ev.pubkey}
|
targetName={profile?.name || ev.pubkey}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<button
|
<button className="message-zap-button" onClick={pickEmoji}>
|
||||||
className="message-zap-button"
|
|
||||||
onClick={() => setShowEmojiPicker(true)}
|
|
||||||
>
|
|
||||||
<Icon name="face" className="message-zap-button-icon" />
|
<Icon name="face" className="message-zap-button-icon" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showEmojiPicker && (
|
{showEmojiPicker && (
|
||||||
<div
|
<EmojiPicker
|
||||||
style={{
|
topOffset={topOffset}
|
||||||
position: "fixed",
|
leftOffset={leftOffset}
|
||||||
top: topOffset - 310,
|
onEmojiSelect={onEmojiSelect}
|
||||||
left: leftOffset,
|
onClickOutside={() => setShowEmojiPicker(false)}
|
||||||
zIndex: 1,
|
|
||||||
}}
|
|
||||||
ref={emojiRef}
|
ref={emojiRef}
|
||||||
>
|
/>
|
||||||
<style>
|
|
||||||
{`
|
|
||||||
em-emoji-picker { max-height: 300px; }
|
|
||||||
`}
|
|
||||||
</style>
|
|
||||||
<Picker
|
|
||||||
data={data}
|
|
||||||
perLine={7}
|
|
||||||
previewPosition="none"
|
|
||||||
skinTonePosition="search"
|
|
||||||
theme="dark"
|
|
||||||
onEmojiSelect={onEmojiSelect}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -353,12 +405,22 @@ function ChatZap({ streamer, ev }: { streamer: string; ev: TaggedRawEvent }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function WriteMessage({ link }: { link: NostrLink }) {
|
function WriteMessage({ link }: { link: NostrLink }) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const emojiRef = useRef(null);
|
||||||
const [chat, setChat] = useState("");
|
const [chat, setChat] = useState("");
|
||||||
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const userEmojis = useEmoji(login!.pubkey);
|
const userEmojiPacks = useEmoji(login!.pubkey);
|
||||||
const channelEmojis = useEmoji(link.author!);
|
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 = userEmojis.concat(channelEmojis);
|
||||||
const names = emojis.map((t) => t.at(1));
|
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() {
|
async function sendChatMessage() {
|
||||||
const pub = await EventPublisher.nip7();
|
const pub = await EventPublisher.nip7();
|
||||||
@ -394,6 +456,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) {
|
async function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.code === "Enter") {
|
if (e.code === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -406,15 +477,33 @@ function WriteMessage({ link }: { link: NostrLink }) {
|
|||||||
setChat(e.target.value);
|
setChat(e.target.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickEmoji(ev: any) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
setShowEmojiPicker(!showEmojiPicker);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="paper">
|
<div className="paper" ref={ref}>
|
||||||
<Textarea
|
<Textarea
|
||||||
emojis={emojis}
|
emojis={emojis}
|
||||||
value={chat}
|
value={chat}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
|
<div onClick={pickEmoji}>
|
||||||
|
<Icon name="face" className="write-emoji-button" />
|
||||||
|
</div>
|
||||||
|
{showEmojiPicker && (
|
||||||
|
<EmojiPicker
|
||||||
|
topOffset={topOffset}
|
||||||
|
leftOffset={leftOffset}
|
||||||
|
emojiPacks={allEmojiPacks}
|
||||||
|
onEmojiSelect={onEmojiSelect}
|
||||||
|
onClickOutside={() => setShowEmojiPicker(false)}
|
||||||
|
ref={emojiRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<AsyncButton onClick={sendChatMessage} className="btn btn-border">
|
<AsyncButton onClick={sendChatMessage} className="btn btn-border">
|
||||||
Send
|
Send
|
||||||
|
@ -1,9 +1,22 @@
|
|||||||
import { RequestBuilder, EventKind, FlatNoteStore } from "@snort/system";
|
import {
|
||||||
|
RequestBuilder,
|
||||||
|
EventKind,
|
||||||
|
ReplaceableNoteStore,
|
||||||
|
ParameterizedReplaceableNoteStore,
|
||||||
|
} from "@snort/system";
|
||||||
import { useRequestBuilder } from "@snort/system-react";
|
import { useRequestBuilder } from "@snort/system-react";
|
||||||
import { System } from "index";
|
import { System } from "index";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { findTag } from "utils";
|
||||||
import type { EmojiTag } from "../element/emoji";
|
import type { EmojiTag } from "../element/emoji";
|
||||||
|
|
||||||
|
export interface EmojiPack {
|
||||||
|
address: string;
|
||||||
|
name: string;
|
||||||
|
author: string;
|
||||||
|
emojis: EmojiTag[];
|
||||||
|
}
|
||||||
|
|
||||||
export default function useEmoji(pubkey: string) {
|
export default function useEmoji(pubkey: string) {
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
const rb = new RequestBuilder(`emoji:${pubkey}`);
|
const rb = new RequestBuilder(`emoji:${pubkey}`);
|
||||||
@ -15,15 +28,15 @@ export default function useEmoji(pubkey: string) {
|
|||||||
return rb;
|
return rb;
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
|
|
||||||
const { data } = useRequestBuilder<FlatNoteStore>(System, FlatNoteStore, sub);
|
const { data: userEmoji } = useRequestBuilder<ReplaceableNoteStore>(
|
||||||
const userEmoji = useMemo(() => {
|
System,
|
||||||
return data ?? [];
|
ReplaceableNoteStore,
|
||||||
}, [data]);
|
sub
|
||||||
|
);
|
||||||
|
|
||||||
const related = useMemo(() => {
|
const related = useMemo(() => {
|
||||||
if (userEmoji) {
|
if (userEmoji) {
|
||||||
const tags = userEmoji.at(0)?.tags ?? [];
|
return userEmoji.tags.filter(
|
||||||
return tags.filter(
|
|
||||||
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`30030:`)
|
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`30030:`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -52,22 +65,26 @@ export default function useEmoji(pubkey: string) {
|
|||||||
return rb;
|
return rb;
|
||||||
}, [pubkey, related]);
|
}, [pubkey, related]);
|
||||||
|
|
||||||
const { data: relatedData } = useRequestBuilder<FlatNoteStore>(
|
const { data: relatedData } =
|
||||||
System,
|
useRequestBuilder<ParameterizedReplaceableNoteStore>(
|
||||||
FlatNoteStore,
|
System,
|
||||||
subRelated
|
ParameterizedReplaceableNoteStore,
|
||||||
);
|
subRelated
|
||||||
|
);
|
||||||
const emojiPacks = useMemo(() => {
|
const emojiPacks = useMemo(() => {
|
||||||
return relatedData ?? [];
|
return relatedData ?? [];
|
||||||
}, [relatedData]);
|
}, [relatedData]);
|
||||||
|
|
||||||
const emojis = useMemo(() => {
|
const emojis = useMemo(() => {
|
||||||
return userEmoji
|
return emojiPacks.map((ev) => {
|
||||||
.concat(emojiPacks)
|
const d = findTag(ev, "d");
|
||||||
.map((ev) => {
|
return {
|
||||||
return ev.tags.filter((t) => t.at(0) === "emoji");
|
address: `${ev.kind}:${ev.pubkey}:${d}`,
|
||||||
})
|
name: d,
|
||||||
.flat() as EmojiTag[];
|
author: ev.pubkey,
|
||||||
|
emojis: ev.tags.filter((t) => t.at(0) === "emoji") as EmojiTag[],
|
||||||
|
} as EmojiPack;
|
||||||
|
});
|
||||||
}, [userEmoji, emojiPacks]);
|
}, [userEmoji, emojiPacks]);
|
||||||
|
|
||||||
return emojis;
|
return emojis;
|
||||||
|
Reference in New Issue
Block a user