feat: support posting custom emoji

This commit is contained in:
Shusui MOYATANI
2023-05-16 23:59:00 +09:00
parent 12e38f85d9
commit ee6731023e
6 changed files with 171 additions and 38 deletions

View File

@@ -3,17 +3,34 @@ import { Component, JSX, createSignal } from 'solid-js';
import { Picker } from 'emoji-mart';
import Popup, { PopupRef } from '@/components/utils/Popup';
import useConfig from '@/core/useConfig';
type EmojiPickerProps = {
onEmojiSelect?: (emoji: string) => void;
customEmojis?: boolean;
children: JSX.Element;
};
const EmojiPicker: Component<EmojiPickerProps> = (props) => {
let popupRef: PopupRef | undefined;
const { config } = useConfig();
const [pickerElement, setPickerElement] = createSignal<HTMLElement | undefined>(undefined);
/*
const buildCustom = () => {
const emojis = Object.values(config().customEmojis).map(({ shortcode, url }) => ({
id: shortcode,
name: shortcode,
keywords: [shortcode],
skins: [{ src: url }],
}));
console.log(emojis);
return [{ id: 'custom_rabbit', name: 'カスタム絵文字', emojis }];
};
*/
const handleOpen = () => {
const picker = new Picker({
data: async () => {
@@ -24,11 +41,12 @@ const EmojiPicker: Component<EmojiPickerProps> = (props) => {
const response = await fetch('https://cdn.jsdelivr.net/npm/@emoji-mart/data/i18n/ja.json');
return response.json();
},
// custom: props.customEmojis ? buildCustom() : [],
autoFocus: false,
locale: 'ja',
theme: 'light',
onEmojiSelect: (emoji: { id: string; native: string }) => {
props.onEmojiSelect?.(emoji.native);
onEmojiSelect: (emoji: { id: string; native?: string }) => {
props.onEmojiSelect?.(emoji.native ?? `:${emoji.id}:`);
popupRef?.close();
},
});

View File

@@ -1,21 +1,14 @@
import {
createSignal,
createMemo,
onMount,
Show,
For,
type Component,
type JSX,
type Accessor,
} from 'solid-js';
import { createSignal, createMemo, onMount, Show, For, type Component, type JSX } from 'solid-js';
import { createMutation } from '@tanstack/solid-query';
import FaceSmile from 'heroicons/24/outline/face-smile.svg';
import Photo from 'heroicons/24/outline/photo.svg';
import XMark from 'heroicons/24/outline/x-mark.svg';
import PaperAirplane from 'heroicons/24/solid/paper-airplane.svg';
import uniq from 'lodash/uniq';
import { Event as NostrEvent } from 'nostr-tools';
import EmojiPicker from '@/components/EmojiPicker';
import UserNameDisplay from '@/components/UserDisplayName';
import useConfig from '@/core/useConfig';
import { useHandleCommand } from '@/hooks/useCommandBus';
@@ -48,12 +41,13 @@ const extract = (parsed: ParsedTextNote) => {
const pubkeyReferences: string[] = [];
const eventReferences: string[] = [];
const urlReferences: string[] = [];
const emojis: string[] = [];
parsed.forEach((node) => {
if (node.type === 'HashTag') {
hashtags.push(node.tagName);
} else if (node.type === 'URL') {
if (node.type === 'URL') {
urlReferences.push(node.content);
} else if (node.type === 'HashTag') {
hashtags.push(node.tagName);
} else if (node.type === 'Bech32Entity') {
if (node.data.type === 'npub') {
pubkeyReferences.push(node.data.data);
@@ -63,14 +57,17 @@ const extract = (parsed: ParsedTextNote) => {
// TODO nevent can contain an event not only textnote (kind:1).
// In my understanding, it is not allowed to include other kinds of events in `tags`.
// It is needed to verify that the kind of the event is 1.
} else if (node.type === 'CustomEmoji' && !emojis.includes(node.shortcode)) {
emojis.push(node.shortcode);
}
});
return {
hashtags,
urlReferences,
pubkeyReferences,
eventReferences,
urlReferences,
emojis,
};
};
@@ -94,6 +91,8 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
const [contentWarning, setContentWarning] = createSignal(false);
const [contentWarningReason, setContentWarningReason] = createSignal('');
const appendText = (s: string) => setText((current) => `${current} ${s}`);
const clearText = () => {
setText('');
setContentWarningReason('');
@@ -106,7 +105,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
props.onClose();
};
const { config } = useConfig();
const { config, getEmoji } = useConfig();
const getPubkey = usePubkey();
const commands = useCommands();
@@ -140,7 +139,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
uploadResults.forEach((result) => {
if (result.status === 'fulfilled') {
console.log('succeeded to upload', result);
setText((current) => `${current} ${result.value.imageUrl}`);
appendText(result.value.imageUrl);
resizeTextArea();
} else {
console.error('failed to upload', result);
@@ -169,6 +168,19 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
]);
};
const buildEmojiTags = (emojis: string[]): string[][] => {
const emojiTags: string[][] = [];
emojis.forEach((shortcode) => {
const emoji = getEmoji(shortcode);
if (emoji != null) {
emojiTags.push(['emoji', shortcode, emoji.url]);
}
});
return emojiTags;
};
const submit = () => {
if (text().length === 0) return;
if (publishTextNoteMutation.isLoading) return;
@@ -180,8 +192,9 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
}
const parsed = parseTextNote(text());
const { hashtags, pubkeyReferences, eventReferences, urlReferences } = extract(parsed);
const { hashtags, urlReferences, pubkeyReferences, eventReferences, emojis } = extract(parsed);
const formattedContent = format(parsed);
const emojiTags = buildEmojiTags(emojis);
let textNote: PublishTextNoteParams = {
relayUrls: config().relayUrls,
@@ -191,6 +204,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
mentionEventIds: eventReferences,
hashtags,
urls: urlReferences,
tags: emojiTags,
};
if (replyTo() != null) {
@@ -329,6 +343,13 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
</button>
</div>
</Show>
{/*
<EmojiPicker customEmojis={true} onEmojiSelect={(emoji) => appendText(emoji)}>
<span class="inline-block h-8 w-8 rounded bg-primary p-2 font-bold text-white">
<FaceSmile />
</span>
</EmojiPicker>
*/}
<button
class="flex items-center justify-center rounded p-2 text-xs font-bold text-white"
classList={{

View File

@@ -13,6 +13,11 @@ type ConfigProps = {
onClose: () => void;
};
const BaseUrlRegex = (schemaRegex: string) =>
`^(?:${schemaRegex})://[-a-zA-Z0-9.]+(:\\d{1,5})?(?:/[-[\\]~!$&'()*+.,:;@&=%\\w]+|/)*(?:\\?[-[\\]~!$&'()*+.,/:;%@&=\\w?]+)?(?:#[-[\\]~!$&'()*+.,/:;%@\\w&=?#]+)?$`;
const HttpUrlRegex = BaseUrlRegex('https?');
const RelayUrlRegex = BaseUrlRegex('wss?');
const ProfileSection = () => {
const pubkey = usePubkey();
const { showProfile, showProfileEdit } = useModalState();
@@ -77,6 +82,7 @@ const RelayConfig = () => {
type="text"
name="relayUrl"
value={relayUrlInput()}
pattern={RelayUrlRegex}
onChange={(ev) => setRelayUrlInput(ev.currentTarget.value)}
/>
<button type="submit" class="rounded bg-rose-300 p-2 font-bold text-white">
@@ -166,7 +172,7 @@ const ToggleButton = (props: {
);
};
const EmojiConfig = () => {
const ReactionConfig = () => {
const { config, setConfig } = useConfig();
const toggleUseEmojiReaction = () => {
@@ -206,6 +212,68 @@ const EmojiConfig = () => {
);
};
const EmojiConfig = () => {
const { config, saveEmoji, removeEmoji } = useConfig();
const [shortcodeInput, setShortcodeInput] = createSignal('');
const [urlInput, setUrlInput] = createSignal('');
const handleClickSaveEmoji: JSX.EventHandler<HTMLFormElement, SubmitEvent> = (ev) => {
ev.preventDefault();
if (shortcodeInput().length === 0 || urlInput().length === 0) return;
saveEmoji({ shortcode: shortcodeInput(), url: urlInput() });
};
return (
<div class="py-2">
<h3 class="font-bold"></h3>
<ul class="flex flex-col gap-1 py-2">
<For each={Object.values(config().customEmojis)}>
{({ shortcode, url }) => (
<li class="flex items-center gap-2">
<img class="h-7 max-w-[128px]" src={url} alt={shortcode} />
<div class="flex-1 truncate">{shortcode}</div>
<button class="h-3 w-3 shrink-0" onClick={() => removeEmoji(shortcode)}>
<XMark />
</button>
</li>
)}
</For>
</ul>
<form class="flex gap-2" onSubmit={handleClickSaveEmoji}>
<label class="flex flex-1 items-center gap-1">
<div></div>
<input
class="flex-1 rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
name="shortcode"
value={shortcodeInput()}
pattern="^[a-zA-Z0-9]+$"
required
onChange={(ev) => setShortcodeInput(ev.currentTarget.value)}
/>
</label>
<label class="flex flex-1 items-center gap-1">
<div>URL</div>
<input
class="flex-1 rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
name="url"
value={urlInput()}
placeholder="https://.../emoji.png"
pattern={HttpUrlRegex}
required
onChange={(ev) => setUrlInput(ev.currentTarget.value)}
/>
</label>
<button type="submit" class="rounded bg-rose-300 p-2 font-bold text-white">
</button>
</form>
</div>
);
};
const MuteConfig = () => {
const { config, removeMutedPubkey, addMutedKeyword, removeMutedKeyword } = useConfig();
@@ -335,6 +403,7 @@ const ConfigUI = (props: ConfigProps) => {
<ProfileSection />
<RelayConfig />
<DateFormatConfig />
<ReactionConfig />
<EmojiConfig />
<OtherConfig />
<MuteConfig />

View File

@@ -104,6 +104,7 @@ const Popup: Component<PopupProps> = (props) => {
return (
<div>
<button
type="button"
ref={buttonRef}
class="flex items-center"
onClick={() => {